Files
watchlist/quivio/static/js/app.js
T
ki 9a4909f083
CI / test (push) Has been cancelled
chore(rebrand): rename WatchStack -> Quivio
Quivio (Kofferwort aus Quire + Video) ersetzt den Arbeitstitel WatchStack.
Es ist origineller, frei auf PyPI/npm, und passt perfekt zur Doppelnatur
der App: Buecher (Quire) + Serien (Video).

Geaendert:
- Python-Paket app/ -> quivio/ (+ alle Imports angepasst)
- DB-File watchstack.db -> quivio.db
- Docker-Image: quivio:0.5.1-beta, Container-Name quivio
- Compose-Services: quivio, quivio-local
- ENV-Vars: QUIVIO_VERSION, QUIVIO_PORT, Volume quivio-data
- Frontend-Logo "W" -> "Q", Titel "Quivio", Footer
- Favicon "Q" (Georgia serif fuer klassischen Look)
- Logger-Name: watchstack -> quivio
- README, NOTICE, dist/README, data/README: Quivio
- CI-Workflow: testet quivio/ Pfade
- build-and-push.sh: lokaler Tag "quivio:VERSION"
- service-Feld in /api/health: quivio
- OpenAPI title: Quivio

Bugfix (gefunden beim Renaming):
- seed.py hatte versteckten "from app.database import DB_PATH" — gefixt

Verifiziert end-to-end:
- Python-Import OK
- Backend: service=quivio, version=0.5.1-beta, 8 unique items
- Docker-Build OK, Push in Gitea-Registry OK (neuer sha256-Digest)
- Container: Quivio 0.5.1-beta ready (logger), HTML ohne WatchStack-Rest
- OpenAPI title=Quivio
- 8 unique items nach cold start (Race-Fix haelt)
2026-07-22 00:42:09 +02:00

527 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================================================
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function stars(rating) {
if (rating == null) return '';
return `<span class="rating-display" title="${rating}/10">★ ${Number(rating).toFixed(1)}</span>`;
}
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 = '<span class="chip-label">Genres:</span>';
t.innerHTML = '<span class="chip-label">Tags:</span>';
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 => `
<article class="card" data-id="${m.id}">
<div class="cover">
<span class="kind-badge">${kindIcon(m.kind)} ${m.kind === 'book' ? 'Buch' : 'Serie'}</span>
${m.cover_url
? `<img src="${escapeHtml(m.cover_url)}" alt="${escapeHtml(m.title)}" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 28))}</div>`}
<div class="progress-bar"><div style="width:${m.progress_percent}%"></div></div>
</div>
<div class="card-body">
<div class="card-title">${escapeHtml(m.title)}</div>
<div class="card-sub">${escapeHtml(m.kind === 'book' ? (m.author || '—') : (m.network || '—'))}${m.release_year ? ' · ' + m.release_year : ''}</div>
<div class="card-meta">
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
${stars(m.rating)}
</div>
<div class="card-sub">${progressText(m)}</div>
</div>
</article>
`).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 = `
<div class="detail-hero">
<div class="row-top">
<div class="cover-mini">
${m.cover_url
? `<img src="${escapeHtml(m.cover_url)}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 20))}</div>`}
</div>
<div class="meta-title">
<h2>${escapeHtml(m.title)}</h2>
<div class="sub">${kindLabel(m.kind)} · ${m.release_year || '—'}${m.author ? ' · ' + escapeHtml(m.author) : ''}${m.network ? ' · ' + escapeHtml(m.network) : ''}</div>
<div class="badges">
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
${stars(m.rating)}
</div>
</div>
</div>
</div>
${m.description ? `<section class="detail-section"><h3>Beschreibung</h3><p>${escapeHtml(m.description)}</p></section>` : ''}
<section class="detail-section">
<h3>Status & Fortschritt</h3>
<div class="progress-bar" style="height:8px;border-radius:8px;overflow:hidden;background:var(--bg-elev-2);">
<div style="width:${m.progress_percent}%;height:100%;background:var(--accent-grad);"></div>
</div>
<div style="margin-top:6px;color:var(--text-dim);font-size:13px;">${progressText(m)} (${m.progress_percent}%)</div>
<div class="progress-controls">
<button class="btn small" id="prog-minus">1</button>
<button class="btn small" id="prog-plus">+1</button>
<input type="number" id="prog-set" min="0" placeholder="absolut">
<button class="btn small btn-primary" id="prog-save">Setzen</button>
</div>
<div class="progress-controls" style="margin-top:14px;">
${['plan','reading','done','hold','dropped'].map(s =>
`<button class="btn small status-btn" data-st="${s}">${statusLabel(s)}</button>`
).join('')}
</div>
</section>
<section class="detail-section">
<h3>Eckdaten</h3>
<div class="stat-grid">
${m.kind === 'book' ? `
<div class="stat"><div class="label">Autor</div><div class="value">${escapeHtml(m.author || '—')}</div></div>
<div class="stat"><div class="label">ISBN</div><div class="value" style="font-size:14px">${escapeHtml(m.isbn || '—')}</div></div>
<div class="stat"><div class="label">Bände</div><div class="value">${m.total_volumes ?? '—'}</div></div>
<div class="stat"><div class="label">Kapitel</div><div class="value">${m.total_chapters ?? '—'}</div></div>
<div class="stat"><div class="label">Seiten</div><div class="value">${m.total_pages ?? '—'}</div></div>
` : `
<div class="stat"><div class="label">Sender</div><div class="value" style="font-size:14px">${escapeHtml(m.network || '—')}</div></div>
<div class="stat"><div class="label">Staffeln</div><div class="value">${m.total_seasons ?? '—'}</div></div>
<div class="stat"><div class="label">Episoden</div><div class="value">${m.total_episodes ?? '—'}</div></div>
<div class="stat"><div class="label">Staffel aktuell</div><div class="value">${m.season_watching ?? '—'}</div></div>
<div class="stat"><div class="label">Start</div><div class="value" style="font-size:14px">${m.start_date || '—'}</div></div>
<div class="stat"><div class="label">Ende</div><div class="value" style="font-size:14px">${m.end_date || '—'}</div></div>
`}
</div>
</section>
${(m.genres && m.genres.length) || (m.tags && m.tags.length) ? `
<section class="detail-section">
<h3>Genres & Tags</h3>
<div class="tag-list">
${(m.genres||[]).map(g => `<span class="chip">${escapeHtml(g.name)}</span>`).join('')}
${(m.tags||[]).map(t => `<span class="chip" style="border-style:dashed">#${escapeHtml(t.name)}</span>`).join('')}
</div>
</section>
` : ''}
${m.notes ? `<section class="detail-section"><h3>Notizen</h3><p style="white-space:pre-wrap">${escapeHtml(m.notes)}</p></section>` : ''}
<div class="actions-row">
<button class="btn btn-primary" id="detail-edit">✎ Bearbeiten</button>
<button class="btn btn-danger" id="detail-delete">🗑 Löschen</button>
</div>
`;
// 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 `
<div class="stat-block">
<h3>${kind === 'book' ? '📚 Bücher' : '📺 Serien'} · ${total} Einträge · ⌀ ${data.avg_rating ?? '—'}</h3>
${data.map(b => `
<div class="bar-row">
<div class="label status-badge status-${b.status}">${statusLabel(b.status)}</div>
<div class="bar"><div style="width:${(b.count / max) * 100}%"></div></div>
<div class="count">${b.count}</div>
</div>
`).join('')}
</div>
`;
};
$('#stats-body').innerHTML = `
<div class="stat-block">
<div class="big-number">${s.total_entries}</div>
<div style="color:var(--text-dim)">Einträge insgesamt</div>
</div>
${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();
});
})();