/* ========================================================================
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 || '?').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))}
`}
${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 || '—'}
`}
${(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();
});
})();