/* ======================================================================== Quivio – Client App ======================================================================== */ (() => { 'use strict'; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const state = { items: [], genres: [], tags: [], filter: { kind: '', status: '', search: '', genre: '', tag: '', sort: 'updated_desc', }, editing: null, // media id when editing, null when creating }; // ---------- Helpers -------------------------------------------------------- const api = { async get(url) { const r = await fetch(url); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); return r.json(); }, async send(method, url, body) { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); if (!r.ok && r.status !== 204) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || r.statusText); } return r.status === 204 ? null : r.json(); }, }; function toast(msg, kind = 'info') { const el = $('#toast'); el.textContent = msg; el.hidden = false; el.style.borderColor = kind === 'error' ? 'rgba(239,68,68,.5)' : kind === 'success' ? 'rgba(34,197,94,.5)' : 'var(--border-strong)'; clearTimeout(toast._t); toast._t = setTimeout(() => { el.hidden = true; }, 2400); } function escapeHtml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function stars(rating) { if (rating == null) return ''; return `★ ${Number(rating).toFixed(1)}`; } function statusLabel(s) { return ({ plan: 'Geplant', reading: 'Laufend', done: 'Abgeschlossen', hold: 'Pausiert', dropped: 'Abgebrochen', })[s] || s; } function kindLabel(k) { return k === 'book' ? '📚 Buch' : '📺 Serie'; } function kindIcon(k) { return k === 'book' ? '📚' : '📺'; } function progressText(m) { if (m.kind === 'book') { const total = m.total_chapters || m.total_pages || m.total_volumes || 0; const done = m.chapters_read || m.pages_read || m.volumes_read || 0; const unit = m.total_chapters ? 'Kap.' : m.total_pages ? 'S.' : 'Bd.'; return total ? `${done} / ${total} ${unit}` : '—'; } const total = m.total_episodes || 0; const done = m.episodes_watched || 0; return total ? `${done} / ${total} Ep.` : '—'; } // ---------- Initial Load ---------------------------------------------------- async function loadAll() { try { const params = new URLSearchParams(); if (state.filter.kind) params.set('kind', state.filter.kind); if (state.filter.status) params.set('status', state.filter.status); if (state.filter.search) params.set('search', state.filter.search); if (state.filter.genre) params.set('genre', state.filter.genre); if (state.filter.tag) params.set('tag', state.filter.tag); params.set('sort', state.filter.sort); const [items, genres, tags] = await Promise.all([ api.get(`/api/media?${params}`), api.get('/api/genres'), api.get('/api/tags'), ]); state.items = items; state.genres = genres; state.tags = tags; renderAll(); } catch (e) { toast('Fehler beim Laden: ' + e.message, 'error'); } } // ---------- Render ---------------------------------------------------------- function renderAll() { renderChips(); renderCounts(); renderGrid(); } function renderChips() { const g = $('#genre-chips'); const t = $('#tag-chips'); g.innerHTML = 'Genres:'; t.innerHTML = 'Tags:'; state.genres.forEach(x => { const el = document.createElement('button'); el.className = 'chip' + (state.filter.genre === x.name ? ' active' : ''); el.textContent = x.name; el.onclick = () => { state.filter.genre = state.filter.genre === x.name ? '' : x.name; loadAll(); }; g.appendChild(el); }); state.tags.forEach(x => { const el = document.createElement('button'); el.className = 'chip' + (state.filter.tag === x.name ? ' active' : ''); el.textContent = x.name; el.onclick = () => { state.filter.tag = state.filter.tag === x.name ? '' : x.name; loadAll(); }; t.appendChild(el); }); } function renderCounts() { const counts = { plan: 0, reading: 0, done: 0, hold: 0, dropped: 0 }; let filtered = state.items; if (state.filter.kind) filtered = filtered.filter(m => m.kind === state.filter.kind); if (state.filter.search) { const s = state.filter.search.toLowerCase(); filtered = filtered.filter(m => (m.title || '').toLowerCase().includes(s) || (m.author || '').toLowerCase().includes(s) || (m.original_title || '').toLowerCase().includes(s) ); } filtered.forEach(m => { counts[m.status] = (counts[m.status] || 0) + 1; }); Object.entries(counts).forEach(([k, v]) => { const el = $('#cnt-' + k); if (el) el.textContent = v; }); } function renderGrid() { const grid = $('#grid'); const empty = $('#empty-state'); if (state.items.length === 0) { grid.innerHTML = ''; empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); grid.innerHTML = state.items.map(m => `
${kindIcon(m.kind)} ${m.kind === 'book' ? 'Buch' : 'Serie'} ${m.cover_url ? `${escapeHtml(m.title)}` : `
${escapeHtml((m.title || '?').slice(0, 28))}
`}
${escapeHtml(m.title)}
${escapeHtml(m.kind === 'book' ? (m.author || '—') : (m.network || '—'))}${m.release_year ? ' · ' + m.release_year : ''}
${statusLabel(m.status)} ${stars(m.rating)}
${progressText(m)}
`).join(''); $$('.card', grid).forEach(el => { el.addEventListener('click', () => openDetail(parseInt(el.dataset.id, 10))); }); } // ---------- Filter / Suche -------------------------------------------------- function wireFilters() { const search = $('#search-input'); let debounce; search.addEventListener('input', () => { clearTimeout(debounce); debounce = setTimeout(() => { state.filter.search = search.value.trim(); loadAll(); }, 220); }); $('#filter-kind').addEventListener('change', e => { state.filter.kind = e.target.value; loadAll(); }); $('#filter-status').addEventListener('change', e => { state.filter.status = e.target.value; loadAll(); }); $('#filter-sort').addEventListener('change', e => { state.filter.sort = e.target.value; loadAll(); }); $$('#status-tabs .tab').forEach(tab => { tab.addEventListener('click', () => { $$('#status-tabs .tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); state.filter.status = tab.dataset.status; $('#filter-status').value = state.filter.status; loadAll(); }); }); } // ---------- Modal: Add / Edit --------------------------------------------- function openAdd() { state.editing = null; $('#modal-title').textContent = 'Neuer Eintrag'; const form = $('#form-add'); form.reset(); form.id.value = ''; form.kind.value = 'book'; document.body.classList.remove('kind-series'); document.body.classList.add('kind-book'); $('#modal-add').hidden = false; } function openEdit(m) { state.editing = m.id; $('#modal-title').textContent = 'Bearbeiten: ' + m.title; const form = $('#form-add'); form.reset(); form.id.value = m.id; form.kind.value = m.kind; document.body.classList.toggle('kind-book', m.kind === 'book'); document.body.classList.toggle('kind-series', m.kind === 'series'); // Felder setzen const setVal = (name, val) => { if (form[name]) form[name].value = val ?? ''; }; setVal('title', m.title); setVal('status', m.status); setVal('release_year', m.release_year); setVal('author', m.author); setVal('isbn', m.isbn); setVal('total_volumes', m.total_volumes); setVal('total_chapters', m.total_chapters); setVal('total_pages', m.total_pages); setVal('network', m.network); setVal('total_seasons', m.total_seasons); setVal('total_episodes', m.total_episodes); setVal('season_watching', m.season_watching); setVal('start_date', m.start_date); setVal('end_date', m.end_date); setVal('cover_url', m.cover_url); setVal('rating', m.rating); setVal('description', m.description); setVal('notes', m.notes); form.genres.value = (m.genres || []).map(g => g.name).join(', '); form.tags.value = (m.tags || []).map(t => t.name).join(', '); $('#modal-add').hidden = false; } function wireAddModal() { const form = $('#form-add'); const modal = $('#modal-add'); $$('[data-close]', modal).forEach(b => b.addEventListener('click', () => { modal.hidden = true; })); form.kind.addEventListener('change', () => { document.body.classList.toggle('kind-book', form.kind.value === 'book'); document.body.classList.toggle('kind-series', form.kind.value === 'series'); }); // Online-Lookup (Open Library per ISBN, sonst Titel) $('#btn-lookup').addEventListener('click', async () => { const isbn = form.isbn.value.trim(); const title = form.title.value.trim(); if (!isbn && !title) { toast('ISBN oder Titel eingeben', 'error'); return; } try { const params = isbn ? `?isbn=${encodeURIComponent(isbn)}` : `?title=${encodeURIComponent(title)}`; const data = await api.get('/api/lookup/book' + params); if (data.title && !form.title.value) form.title.value = data.title; if (data.author && !form.author.value) form.author.value = data.author; if (data.release_year && !form.release_year.value) form.release_year.value = data.release_year; if (data.total_pages && !form.total_pages.value) form.total_pages.value = data.total_pages; if (data.cover_url && !form.cover_url.value) form.cover_url.value = data.cover_url; toast('Daten geladen ✓', 'success'); } catch (e) { toast('Lookup fehlgeschlagen: ' + e.message, 'error'); } }); form.addEventListener('submit', async e => { e.preventDefault(); const fd = new FormData(form); const body = {}; for (const [k, v] of fd.entries()) { if (k === 'id') continue; body[k] = v; } // Numerische Felder const intFields = ['release_year','total_volumes','total_chapters','total_pages', 'total_seasons','total_episodes','season_watching']; intFields.forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseInt(body[f], 10); }); ['rating'].forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseFloat(body[f]); }); body.genres = (body.genres || '').split(',').map(s => s.trim()).filter(Boolean); body.tags = (body.tags || '').split(',').map(s => s.trim()).filter(Boolean); try { if (state.editing) { await api.send('PATCH', `/api/media/${state.editing}`, body); toast('Aktualisiert ✓', 'success'); } else { await api.send('POST', '/api/media', body); toast('Hinzugefügt ✓', 'success'); } modal.hidden = true; await loadAll(); if (state.editing) openDetail(state.editing); } catch (err) { toast('Speichern fehlgeschlagen: ' + err.message, 'error'); } }); } // ---------- Drawer: Detail ------------------------------------------------- async function openDetail(id) { const m = await api.get('/api/media/' + id).catch(() => null); if (!m) { toast('Eintrag nicht gefunden', 'error'); return; } const c = $('#drawer-content'); c.innerHTML = `
${m.cover_url ? `` : `
${escapeHtml((m.title || '?').slice(0, 20))}
`}

${escapeHtml(m.title)}

${kindLabel(m.kind)} · ${m.release_year || '—'}${m.author ? ' · ' + escapeHtml(m.author) : ''}${m.network ? ' · ' + escapeHtml(m.network) : ''}
${statusLabel(m.status)} ${stars(m.rating)}
${m.description ? `

Beschreibung

${escapeHtml(m.description)}

` : ''}

Status & Fortschritt

${progressText(m)} (${m.progress_percent}%)
${['plan','reading','done','hold','dropped'].map(s => `` ).join('')}

Eckdaten

${m.kind === 'book' ? `
Autor
${escapeHtml(m.author || '—')}
ISBN
${escapeHtml(m.isbn || '—')}
Bände
${m.total_volumes ?? '—'}
Kapitel
${m.total_chapters ?? '—'}
Seiten
${m.total_pages ?? '—'}
` : `
Sender
${escapeHtml(m.network || '—')}
Staffeln
${m.total_seasons ?? '—'}
Episoden
${m.total_episodes ?? '—'}
Staffel aktuell
${m.season_watching ?? '—'}
Start
${m.start_date || '—'}
Ende
${m.end_date || '—'}
`}
${(m.genres && m.genres.length) || (m.tags && m.tags.length) ? `

Genres & Tags

${(m.genres||[]).map(g => `${escapeHtml(g.name)}`).join('')} ${(m.tags||[]).map(t => `#${escapeHtml(t.name)}`).join('')}
` : ''} ${m.notes ? `

Notizen

${escapeHtml(m.notes)}

` : ''}
`; // wire drawer actions $('#detail-edit').onclick = () => { closeDrawer(); openEdit(m); }; $('#detail-delete').onclick = async () => { if (!confirm(`"${m.title}" wirklich löschen?`)) return; try { await api.send('DELETE', `/api/media/${m.id}`); toast('Gelöscht', 'success'); closeDrawer(); loadAll(); } catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); } }; $('#prog-plus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: 1 }); openDetail(m.id); loadAll(); }; $('#prog-minus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: -1 }); openDetail(m.id); loadAll(); }; $('#prog-save').onclick = async () => { const v = parseInt($('#prog-set').value, 10); if (Number.isNaN(v)) return; await api.send('POST', `/api/media/${m.id}/progress`, { set_to: v }); openDetail(m.id); loadAll(); }; $$('.status-btn', c).forEach(btn => btn.onclick = async () => { await api.send('PATCH', `/api/media/${m.id}`, { status: btn.dataset.st }); openDetail(m.id); loadAll(); }); $('#drawer').hidden = false; } function closeDrawer() { $('#drawer').hidden = true; } function wireDrawer() { $$('[data-close-drawer]').forEach(b => b.addEventListener('click', closeDrawer)); document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (!$('#modal-add').hidden) $('#modal-add').hidden = true; else if (!$('#modal-stats').hidden) $('#modal-stats').hidden = true; else if (!$('#drawer').hidden) closeDrawer(); } }); } // ---------- Stats ----------------------------------------------------------- async function openStats() { try { const s = await api.get('/api/stats'); const block = (kind, data, total) => { const max = Math.max(1, ...data.map(b => b.count)); return `

${kind === 'book' ? '📚 Bücher' : '📺 Serien'} · ${total} Einträge · ⌀ ${data.avg_rating ?? '—'}

${data.map(b => `
${statusLabel(b.status)}
${b.count}
`).join('')}
`; }; $('#stats-body').innerHTML = `
${s.total_entries}
Einträge insgesamt
${block('book', s.books.by_status, s.books.total)} ${block('series', s.series.by_status, s.series.total)} `; $('#modal-stats').hidden = false; } catch (e) { toast('Stats laden fehlgeschlagen', 'error'); } } // ---------- Bootstrap ------------------------------------------------------- function wire() { wireFilters(); wireAddModal(); wireDrawer(); $('#open-add').addEventListener('click', openAdd); $('#open-stats').addEventListener('click', openStats); $$('[data-close-stats]').forEach(b => b.addEventListener('click', () => { $('#modal-stats').hidden = true; })); } // Version aus /api/health in den Footer schreiben (async () => { try { const h = await api.get('/api/health'); const el = $('#version'); if (el && h.version) el.textContent = 'v' + h.version; } catch (_) { /* silent */ } })(); document.addEventListener('DOMContentLoaded', () => { wire(); loadAll(); }); })();