'use strict'; // ── Theme ───────────────────────────────────────────────────────────────────── const savedTheme = localStorage.getItem('bagg_theme'); if (savedTheme) document.documentElement.setAttribute('data-theme', savedTheme); function isDark() { const t = document.documentElement.getAttribute('data-theme'); if (t === 'dark') return true; if (t === 'light') return false; return window.matchMedia('(prefers-color-scheme: dark)').matches; } function applyThemeIcons() { const dark = isDark(); document.getElementById('icon-sun').style.display = dark ? 'block' : 'none'; document.getElementById('icon-moon').style.display = dark ? 'none' : 'block'; } document.getElementById('btn-theme').addEventListener('click', () => { const next = isDark() ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('bagg_theme', next); applyThemeIcons(); }); applyThemeIcons(); // ── Offline detection ───────────────────────────────────────────────────────── const offlineBanner = document.getElementById('offline-banner'); function updateOnlineStatus() { offlineBanner.classList.toggle('show', !navigator.onLine); } window.addEventListener('online', updateOnlineStatus); window.addEventListener('offline', updateOnlineStatus); updateOnlineStatus(); // ── Toast ───────────────────────────────────────────────────────────────────── function showToast(msg, duration = 2500) { const el = document.createElement('div'); el.className = 'toast'; el.textContent = msg; document.getElementById('toasts').appendChild(el); setTimeout(() => el.remove(), duration); } // ── State ───────────────────────────────────────────────────────────────────── const state = { filter: 'all', // all | unread | starred | archived page: 1, pages: 1, total: 0, entries: [], loading: false, }; // ── Logout ──────────────────────────────────────────────────────────────────── document.getElementById('btn-logout').addEventListener('click', async () => { await fetch('/web/logout', { method: 'POST' }); location.href = '/login.html'; }); // ── Tab switching ───────────────────────────────────────────────────────────── document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.filter = btn.dataset.filter; state.page = 1; loadEntries(); }); }); // ── API helpers ─────────────────────────────────────────────────────────────── async function apiFetch(path, opts = {}) { const r = await fetch(path, { headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, ...opts, }); if (r.status === 204) return null; const data = await r.json(); if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); return data; } // ── Load entries ────────────────────────────────────────────────────────────── async function loadEntries() { if (state.loading) return; state.loading = true; renderEntries(); const params = new URLSearchParams({ page: state.page, perPage: 50 }); if (state.filter === 'unread') { params.set('archive', '0'); } if (state.filter === 'archived') { params.set('archive', '1'); } if (state.filter === 'starred') { params.set('starred', '1'); } try { const data = await apiFetch(`/web/api/entries?${params}`); state.entries = data.items; state.page = data.page; state.pages = data.pages; state.total = data.total; } catch (e) { showToast('Failed to load entries'); } state.loading = false; renderEntries(); } // ── Render ──────────────────────────────────────────────────────────────────── function timeAgo(isoStr) { const secs = Math.floor((Date.now() - new Date(isoStr)) / 1000); if (secs < 60) return 'just now'; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; if (secs < 86400 * 30) return `${Math.floor(secs / 86400)}d ago`; return new Date(isoStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } function faviconUrl(domain) { if (!domain) return null; return `https://www.google.com/s2/favicons?sz=32&domain=${encodeURIComponent(domain)}`; } function entryHTML(e) { const domain = e.domain_name || ''; const title = e.title || e.url; const fav = faviconUrl(domain); const favHTML = fav ? `` : domain.slice(0, 2).toUpperCase(); return `
${favHTML}
${escHtml(title)}
`; } function renderEntries() { const container = document.getElementById('entries-container'); if (state.loading) { container.innerHTML = `

Loading…

`; return; } if (state.entries.length === 0) { const msgs = { all: ['No links saved yet.', 'Tap + to save your first link.'], unread: ['Nothing to read.', 'Links you haven\'t archived appear here.'], starred: ['No starred links.', 'Star important links to find them quickly.'], archived: ['Nothing archived.', 'Archived links live here.'], }; const [title, sub] = msgs[state.filter] || msgs.all; container.innerHTML = `

${title}

${sub}
`; return; } let html = '
' + state.entries.map(entryHTML).join('') + '
'; if (state.pages > 1) { html += ` `; } container.innerHTML = html; // Pagination container.querySelector('#btn-prev')?.addEventListener('click', () => { state.page--; loadEntries(); }); container.querySelector('#btn-next')?.addEventListener('click', () => { state.page++; loadEntries(); }); // Entry actions (event delegation) container.addEventListener('click', handleEntryAction); } function escHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── Entry actions ───────────────────────────────────────────────────────────── async function handleEntryAction(e) { const btn = e.target.closest('[data-action]'); if (!btn) return; const id = +btn.dataset.id; const action = btn.dataset.action; const entry = state.entries.find(en => en.id === id); if (!entry) return; if (action === 'star') { try { const updated = await apiFetch(`/web/api/entries/${id}`, { method: 'PATCH', body: JSON.stringify({ is_starred: entry.is_starred ? 0 : 1 }), }); Object.assign(entry, updated); renderEntries(); } catch { showToast('Failed to update'); } } if (action === 'archive') { try { const updated = await apiFetch(`/web/api/entries/${id}`, { method: 'PATCH', body: JSON.stringify({ is_archived: entry.is_archived ? 0 : 1 }), }); Object.assign(entry, updated); // Remove from current view if filter doesn't match if (state.filter === 'unread' && updated.is_archived) state.entries = state.entries.filter(en => en.id !== id); if (state.filter === 'archived' && !updated.is_archived) state.entries = state.entries.filter(en => en.id !== id); renderEntries(); showToast(updated.is_archived ? 'Archived' : 'Moved to unread'); } catch { showToast('Failed to update'); } } if (action === 'delete') { if (!confirm('Delete this link?')) return; try { await apiFetch(`/web/api/entries/${id}`, { method: 'DELETE' }); state.entries = state.entries.filter(en => en.id !== id); renderEntries(); showToast('Deleted'); } catch { showToast('Failed to delete'); } } } // ── Add link modal ──────────────────────────────────────────────────────────── const modalAdd = document.getElementById('modal-add'); const inputUrl = document.getElementById('input-url'); const inputTitle = document.getElementById('input-title'); document.getElementById('btn-add').addEventListener('click', () => { modalAdd.classList.add('open'); inputUrl.value = ''; inputTitle.value = ''; setTimeout(() => inputUrl.focus(), 50); }); document.getElementById('btn-modal-cancel').addEventListener('click', closeModal); modalAdd.addEventListener('click', e => { if (e.target === modalAdd) closeModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); function closeModal() { modalAdd.classList.remove('open'); } document.getElementById('btn-modal-save').addEventListener('click', saveLink); inputUrl.addEventListener('keydown', e => { if (e.key === 'Enter') saveLink(); }); async function saveLink() { const url = inputUrl.value.trim(); const title = inputTitle.value.trim(); if (!url) { inputUrl.focus(); return; } // Basic URL sanity try { new URL(url); } catch { showToast('Invalid URL'); inputUrl.focus(); return; } const btn = document.getElementById('btn-modal-save'); btn.disabled = true; btn.textContent = 'Saving…'; try { const entry = await apiFetch('/web/api/entries', { method: 'POST', body: JSON.stringify({ url, title: title || undefined }), }); closeModal(); showToast('Saved!'); // Prepend to list if on 'all' or 'unread' filter if (state.filter === 'all' || state.filter === 'unread') { state.entries.unshift(entry); state.total++; renderEntries(); } } catch (e) { if (e.message?.includes('Already')) { showToast('Already saved'); closeModal(); } else { showToast('Failed to save'); } } finally { btn.disabled = false; btn.textContent = 'Save'; } } // ── Service Worker ──────────────────────────────────────────────────────────── if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); } // ── PWA install prompt ──────────────────────────────────────────────────────── let deferredPrompt = null; window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredPrompt = e; // Show subtle install hint after a delay setTimeout(() => { if (deferredPrompt) showToast('Tip: add bagg to your home screen', 4000); }, 8000); }); // ── Init ────────────────────────────────────────────────────────────────────── loadEntries();