bagg-app/public/app.js
bagg-builder 1adf88d195 phase0: wallabag-compat API + PWA frontend
- Wallabag v2 API compatible (OAuth2, entries CRUD, tags)
- Express + SQLite (better-sqlite3), zero extra deps
- Gated web UI with session auth
- PWA: service worker, manifest, offline support
- Mobile-first design, dark mode, FAB + modal
2026-05-02 22:35:27 +00:00

384 lines
15 KiB
JavaScript

'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
? `<img src="${fav}" alt="" loading="lazy" onerror="this.style.display='none'">`
: domain.slice(0, 2).toUpperCase();
return `
<div class="entry-card ${e.is_archived ? 'archived' : ''}" data-id="${e.id}">
<div class="entry-favicon">${favHTML}</div>
<div class="entry-body">
<div class="entry-title"
title="${escHtml(title)}"
onclick="window.open('${escHtml(e.url)}','_blank','noopener')"
style="cursor:pointer"
>${escHtml(title)}</div>
<div class="entry-meta">
${domain ? `<span class="entry-domain">${escHtml(domain)}</span>` : ''}
<span>${timeAgo(e.created_at)}</span>
${e.reading_time > 0 ? `<span>${e.reading_time} min</span>` : ''}
</div>
</div>
<div class="entry-actions">
<button class="action-btn ${e.is_starred ? 'active-star' : ''}"
data-action="star" data-id="${e.id}"
title="${e.is_starred ? 'Unstar' : 'Star'}"
aria-label="${e.is_starred ? 'Unstar' : 'Star'}">
<svg width="15" height="15" fill="${e.is_starred ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</button>
<button class="action-btn ${e.is_archived ? 'active-archive' : ''}"
data-action="archive" data-id="${e.id}"
title="${e.is_archived ? 'Unarchive' : 'Archive'}"
aria-label="${e.is_archived ? 'Unarchive' : 'Archive'}">
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
</svg>
</button>
<button class="action-btn danger"
data-action="delete" data-id="${e.id}"
title="Delete" aria-label="Delete">
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
</div>`;
}
function renderEntries() {
const container = document.getElementById('entries-container');
if (state.loading) {
container.innerHTML = `<div class="loading"><div class="spinner"></div><p>Loading…</p></div>`;
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 = `
<div class="empty-state">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
<p>${title}</p>
<small>${sub}</small>
</div>`;
return;
}
let html = '<div class="entries-list">' + state.entries.map(entryHTML).join('') + '</div>';
if (state.pages > 1) {
html += `
<div class="pagination">
<button class="page-btn" id="btn-prev" ${state.page <= 1 ? 'disabled' : ''}>Prev</button>
<span class="page-info">${state.page} / ${state.pages}</span>
<button class="page-btn" id="btn-next" ${state.page >= state.pages ? 'disabled' : ''}>Next</button>
</div>`;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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();