const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); let libraries = []; async function api(path, opts = {}) { const resp = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts, }); if (!resp.ok) { let msg = resp.statusText; try { msg = (await resp.json()).detail || msg; } catch {} throw new Error(msg); } return resp.json(); } function toast(msg, isError = false) { const t = $("#toast"); t.textContent = msg; t.className = isError ? "error" : ""; t.hidden = false; setTimeout(() => (t.hidden = true), 4000); } function esc(s) { const d = document.createElement("div"); d.textContent = s ?? ""; return d.innerHTML; } // ---- navigation ---- $$("nav button").forEach((btn) => btn.addEventListener("click", () => { $$("nav button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); $$("main > section").forEach((s) => (s.hidden = true)); $("#view-" + btn.dataset.view).hidden = false; if (btn.dataset.view === "missing") loadRequests("missing"); if (btn.dataset.view === "imported") loadRequests("imported"); if (btn.dataset.view === "settings") loadLibraries(); }) ); // ---- libraries ---- async function loadLibraries() { libraries = await api("/api/libraries"); const tbody = $("#lib-table tbody"); tbody.innerHTML = libraries .map( (l) => ` ${esc(l.name)}${esc(l.media_type)}${esc(l.root_path)} ${esc(l.folder_template)}${esc(l.file_template)} ` ) .join(""); tbody.querySelectorAll("[data-del-lib]").forEach((b) => b.addEventListener("click", async () => { if (!confirm("Library löschen?")) return; try { await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" }); loadLibraries(); } catch (e) { toast(e.message, true); } }) ); const filter = $("#missing-filter-library"); filter.innerHTML = '' + libraries.map((l) => ``).join(""); } $("#lib-form").addEventListener("submit", async (e) => { e.preventDefault(); const data = Object.fromEntries(new FormData(e.target)); if (!data.folder_template) delete data.folder_template; if (!data.file_template) delete data.file_template; try { await api("/api/libraries", { method: "POST", body: JSON.stringify(data) }); e.target.reset(); loadLibraries(); toast("Library angelegt"); } catch (err) { toast(err.message, true); } }); function libOptions(mediaType) { return libraries .filter((l) => l.media_type === mediaType) .map((l) => ``) .join(""); } // ---- search & request ---- $("#search-form").addEventListener("submit", async (e) => { e.preventDefault(); const type = $("#search-type").value; const q = $("#search-q").value; const box = $("#search-results"); box.innerHTML = "

Suche läuft…

"; try { const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`); if (!results.length) { box.innerHTML = "

Keine Treffer.

"; return; } const opts = libOptions(type); box.innerHTML = results .map( (r, i) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)} ${esc(r.authors)} ${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""} ${type === "comic" ? `` : ""}
` ) .join(""); box.querySelectorAll("[data-req]").forEach((btn) => btn.addEventListener("click", async () => { const i = btn.dataset.req; const r = results[i]; const libId = box.querySelector(`[data-lib="${i}"]`).value; if (!libId) { toast("Erst eine Library für diesen Typ anlegen (Tab Libraries)", true); return; } const volInput = box.querySelector(`[data-vol="${i}"]`); try { await api("/api/requests", { method: "POST", body: JSON.stringify({ library_id: parseInt(libId), title: r.title, authors: r.authors, external_id: r.external_id, year: r.year, series: r.series, cover_url: r.cover_url, volume: volInput && volInput.value ? parseInt(volInput.value) : null, }), }); btn.textContent = "✓ Angefragt"; btn.disabled = true; } catch (err) { toast(err.message, true); } }) ); } catch (err) { box.innerHTML = ""; toast(err.message, true); } }); // ---- manual request ---- $("#manual-btn").addEventListener("click", () => { const dlg = $("#manual-dialog"); const typeSel = dlg.querySelector("[name=media_type]"); const updateLibs = () => { $("#manual-library").innerHTML = libOptions(typeSel.value) || ""; }; typeSel.onchange = updateLibs; updateLibs(); dlg.showModal(); }); $("#manual-form").addEventListener("submit", async (e) => { if (e.submitter && e.submitter.value === "cancel") return; const data = Object.fromEntries(new FormData(e.target)); if (!data.library_id) { toast("Keine Library gewählt", true); return; } try { await api("/api/requests", { method: "POST", body: JSON.stringify({ library_id: parseInt(data.library_id), title: data.title, authors: data.authors, series: data.series, external_id: data.external_id, year: data.year ? parseInt(data.year) : null, volume: data.volume ? parseInt(data.volume) : null, }), }); e.target.reset(); toast("Anfrage angelegt"); } catch (err) { toast(err.message, true); } }); // ---- missing / imported lists ---- async function loadRequests(status) { const params = new URLSearchParams({ status }); if (status === "missing") { const t = $("#missing-filter-type").value; const l = $("#missing-filter-library").value; if (t) params.set("media_type", t); if (l) params.set("library_id", l); } const reqs = await api("/api/requests?" + params); const box = status === "missing" ? $("#missing-list") : $("#imported-list"); if (!reqs.length) { box.innerHTML = "

Nichts hier.

"; return; } box.innerHTML = reqs .map( (r) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} ${esc(r.authors)} ${esc(r.media_type)} → ${esc(r.library_name)} ${status === "imported" ? `${esc(r.imported_path)}` : ""}
` ) .join(""); box.querySelectorAll("[data-del-req]").forEach((b) => b.addEventListener("click", async () => { if (!confirm("Anfrage entfernen?")) return; await api("/api/requests/" + b.dataset.delReq, { method: "DELETE" }); loadRequests(status); }) ); } $("#missing-filter-type").addEventListener("change", () => loadRequests("missing")); $("#missing-filter-library").addEventListener("change", () => loadRequests("missing")); // ---- import ---- let scanItems = []; let missingReqs = []; $("#scan-btn").addEventListener("click", async () => { $("#scan-info").textContent = "Scanne…"; try { const [scan, reqs] = await Promise.all([ api("/api/import/scan"), api("/api/requests?status=missing"), ]); scanItems = scan.items; missingReqs = reqs; $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`; renderImportTable(); } catch (err) { $("#scan-info").textContent = ""; toast(err.message, true); } }); function renderImportTable() { const table = $("#import-table"); const tbody = table.querySelector("tbody"); if (!scanItems.length) { table.hidden = true; $("#import-btn").hidden = true; return; } tbody.innerHTML = scanItems .map((item, i) => { const opts = missingReqs .filter((r) => r.media_type === item.media_type) .map( (r) => `` ) .join(""); return ` ${esc(item.name)}${item.is_dir ? " 📁" : ""} ${esc(item.media_type)} ${item.suggested_request_id ? item.score : "—"} `; }) .join(""); table.hidden = false; $("#import-btn").hidden = false; } $("#import-btn").addEventListener("click", async () => { const items = []; scanItems.forEach((item, i) => { const checked = document.querySelector(`[data-check="${i}"]`).checked; const reqId = document.querySelector(`[data-select="${i}"]`).value; if (checked && reqId) { items.push({ path: item.path, is_dir: item.is_dir, files: item.files, request_id: parseInt(reqId) }); } }); if (!items.length) { toast("Nichts ausgewählt", true); return; } try { const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items }) }); $("#import-results").innerHTML = res.results .map((r) => r.ok ? `

✓ ${esc(r.path)} → ${esc(r.dest)}

` : `

✗ ${esc(r.path)}: ${esc(r.error)}

` ) .join(""); $("#scan-btn").click(); } catch (err) { toast(err.message, true); } }); // ---- init ---- loadLibraries();