- 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
384 lines
15 KiB
JavaScript
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, '&')
|
|
.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();
|