diff --git a/static/app.js b/static/app.js index d69ddaa..2123426 100644 --- a/static/app.js +++ b/static/app.js @@ -30,6 +30,31 @@ function esc(s) { return d.innerHTML; } +// last used library per media type (per-browser convenience) +function getLastLib(type) { + try { return localStorage.getItem("wordarr.lastLib." + type); } catch { return null; } +} +function setLastLib(type, id) { + try { localStorage.setItem("wordarr.lastLib." + type, String(id)); } catch {} +} +function libOptions(mediaType) { + const last = getLastLib(mediaType); + return libraries + .filter((l) => l.media_type === mediaType) + .map((l) => ``) + .join(""); +} + +// ---- missing badge ---- +async function refreshMissingBadge() { + try { + const reqs = await api("/api/requests?status=missing"); + const badge = $("#missing-badge"); + badge.textContent = reqs.length; + badge.hidden = reqs.length === 0; + } catch {} +} + // ---- navigation ---- $$("nav button").forEach((btn) => btn.addEventListener("click", () => { @@ -102,28 +127,34 @@ $("#lib-form").addEventListener("submit", async (e) => { }); } -function libOptions(mediaType) { - return libraries - .filter((l) => l.media_type === mediaType) - .map((l) => ``) - .join(""); -} - // ---- search & request ---- +const EMPTY_HINTS = { + ebook: "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen.", + audiobook: "Keine Treffer. Tipp: EAN/ISBN funktioniert bei Audible nicht — nach Titel/Autor suchen.", + comic: "Keine Treffer bei AniList (Manga). Westliche Comics per „Manuell anlegen" erfassen.", +}; + +$("#search-type").addEventListener("change", () => ($("#search-results").innerHTML = "")); + $("#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…

"; + const searchBtn = $("#search-btn"); + searchBtn.disabled = true; + searchBtn.innerHTML = ' Suche…'; try { const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`); - if (!results.length) { box.innerHTML = "

Keine Treffer.

"; return; } + if (!results.length) { + box.innerHTML = `

${EMPTY_HINTS[type]}

`; + return; + } const opts = libOptions(type); box.innerHTML = results .map( (r, i) => `
- ${r.cover_url ? `` : '
?
'} + ${r.cover_url ? `` : '
?
'}
${esc(r.title)} ${esc(r.authors)} @@ -156,14 +187,19 @@ $("#search-form").addEventListener("submit", async (e) => { volume: volInput && volInput.value ? parseInt(volInput.value) : null, }), }); + setLastLib(type, libId); btn.textContent = "✓ Angefragt"; btn.disabled = true; + refreshMissingBadge(); } catch (err) { toast(err.message, true); } }) ); } catch (err) { box.innerHTML = ""; toast(err.message, true); + } finally { + searchBtn.disabled = false; + searchBtn.textContent = "Suchen"; } }); @@ -215,11 +251,21 @@ $("#manual-form").addEventListener("submit", async (e) => { }); toast("Anfrage angelegt"); } + setLastLib(data.media_type, data.library_id); e.target.reset(); + refreshMissingBadge(); } catch (err) { toast(err.message, true); } }); // ---- missing / imported lists ---- +let currentRequests = { missing: [], imported: [] }; + +function requestMatchesFilter(r, text) { + if (!text) return true; + const hay = `${r.title} ${r.authors} ${r.series} ${r.narrator}`.toLowerCase(); + return hay.includes(text.toLowerCase()); +} + async function loadRequests(status) { const params = new URLSearchParams({ status }); if (status === "missing") { @@ -228,37 +274,133 @@ async function loadRequests(status) { if (t) params.set("media_type", t); if (l) params.set("library_id", l); } - const reqs = await api("/api/requests?" + params); + currentRequests[status] = await api("/api/requests?" + params); + renderRequests(status); + if (status === "missing") refreshMissingBadge(); +} + +function renderRequests(status) { + const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value; + const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text)); const box = status === "missing" ? $("#missing-list") : $("#imported-list"); - if (!reqs.length) { box.innerHTML = "

Nichts hier.

"; return; } + if (!reqs.length) { + box.innerHTML = "

Nichts hier.

"; + if (status === "missing") $("#missing-bulk-delete").hidden = true; + return; + } box.innerHTML = reqs .map( - (r) => `
- ${r.cover_url ? `` : '
?
'} + (r) => `
+ ${r.cover_url ? `` : '
?
'}
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} ${esc(r.authors)} + ${r.narrator ? `🎙 ${esc(r.narrator)}` : ""} ${esc(r.media_type)} → ${esc(r.library_name)} ${status === "imported" ? `${esc(r.imported_path)}` : ""} -
+ ${status === "missing" ? `` : ""}
` ) .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); + box.querySelectorAll("[data-detail]").forEach((card) => + card.addEventListener("click", (ev) => { + if (ev.target.closest("input,button,select,a")) return; + openDetail(parseInt(card.dataset.detail), status); }) ); + if (status === "missing") { + const updateBulkBtn = () => { + const n = box.querySelectorAll("[data-sel-req]:checked").length; + const btn = $("#missing-bulk-delete"); + btn.hidden = n === 0; + btn.textContent = `Ausgewählte entfernen (${n})`; + }; + box.querySelectorAll("[data-sel-req]").forEach((c) => + c.addEventListener("change", updateBulkBtn) + ); + updateBulkBtn(); + } } + $("#missing-filter-type").addEventListener("change", () => loadRequests("missing")); $("#missing-filter-library").addEventListener("change", () => loadRequests("missing")); +$("#missing-search").addEventListener("input", () => renderRequests("missing")); +$("#imported-search").addEventListener("input", () => renderRequests("imported")); + +$("#missing-bulk-delete").addEventListener("click", async () => { + const ids = [...$("#missing-list").querySelectorAll("[data-sel-req]:checked")] + .map((c) => c.dataset.selReq); + if (!ids.length) return; + if (!confirm(`${ids.length} Anfrage(n) entfernen?`)) return; + for (const id of ids) { + try { await api("/api/requests/" + id, { method: "DELETE" }); } + catch (err) { toast(err.message, true); } + } + toast(`${ids.length} Anfrage(n) entfernt`); + loadRequests("missing"); +}); + +// ---- request detail dialog ---- +let detailRequest = null; + +function openDetail(id, status) { + const r = currentRequests[status].find((x) => x.id === id); + if (!r) return; + detailRequest = r; + const form = $("#detail-form"); + form.title.value = r.title; + form.authors.value = r.authors || ""; + form.narrator.value = r.narrator || ""; + form.series.value = r.series || ""; + form.volume.value = r.volume ?? ""; + form.year.value = r.year ?? ""; + form.external_id.value = r.external_id || ""; + $("#detail-library").innerHTML = libraries + .filter((l) => l.media_type === r.media_type) + .map((l) => ``) + .join(""); + $("#detail-title").textContent = r.title; + $("#detail-status").textContent = + `${r.media_type} · ${r.status === "missing" ? "fehlt noch" : "importiert"}`; + $("#detail-path").textContent = r.imported_path || ""; + const img = $("#detail-cover"); + img.hidden = !r.cover_url; + $("#detail-nocover").hidden = !!r.cover_url; + if (r.cover_url) img.src = r.cover_url; + $("#detail-dialog").showModal(); +} + +$("#detail-form").addEventListener("submit", async (e) => { + if (e.submitter && e.submitter.value === "cancel") return; + if (!detailRequest) return; + const form = e.target; + try { + await api("/api/requests/" + detailRequest.id, { + method: "PUT", + body: JSON.stringify({ + library_id: parseInt(form.library_id.value), + title: form.title.value, + authors: form.authors.value, + narrator: form.narrator.value, + series: form.series.value, + volume: form.volume.value ? parseInt(form.volume.value) : null, + year: form.year.value ? parseInt(form.year.value) : null, + external_id: form.external_id.value, + cover_url: detailRequest.cover_url || "", + }), + }); + toast("Anfrage gespeichert"); + loadRequests(detailRequest.status); + } catch (err) { toast(err.message, true); } +}); // ---- import ---- let scanItems = []; let missingReqs = []; +let viewIdx = []; // indices into scanItems after filter/sort +let importPage = 0; +const IMPORT_PAGE_SIZE = 25; $("#scan-btn").addEventListener("click", async () => { const scanBtn = $("#scan-btn"); @@ -271,7 +413,7 @@ $("#scan-btn").addEventListener("click", async () => { api("/api/requests?status=missing"), ]); scanItems = scan.items; - // selection state lives here, not in the DOM, so it survives paging + // selection state lives here, not in the DOM, so it survives paging/filtering scanItems.forEach((item) => { item.request_id = item.suggested_request_id; item.checked = !!item.suggested_request_id; @@ -279,6 +421,7 @@ $("#scan-btn").addEventListener("click", async () => { missingReqs = reqs; importPage = 0; $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`; + $("#import-toolbar").hidden = scanItems.length === 0; renderImportTable(); } catch (err) { $("#scan-info").textContent = ""; @@ -288,26 +431,51 @@ $("#scan-btn").addEventListener("click", async () => { } }); -let importPage = 0; -const IMPORT_PAGE_SIZE = 25; +function applyImportView() { + const text = $("#import-filter-text").value.toLowerCase(); + const mode = $("#import-filter-mode").value; + const sort = $("#import-sort").value; + viewIdx = scanItems + .map((_, i) => i) + .filter((i) => { + const item = scanItems[i]; + if (text && !(`${item.name} ${item.rel_dir || ""}`.toLowerCase().includes(text))) return false; + if (mode === "suggested" && !item.suggested_request_id) return false; + if (mode === "unassigned" && item.request_id) return false; + return true; + }); + if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score); + if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name)); +} + +function scoreCell(item) { + if (!item.suggested_request_id) return '—'; + const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo"; + return `${item.score}`; +} function renderImportTable() { + applyImportView(); const table = $("#import-table"); const tbody = table.querySelector("tbody"); - if (!scanItems.length) { + if (!viewIdx.length) { table.hidden = true; - $("#import-btn").hidden = true; + $("#import-btn").hidden = scanItems.length === 0; $("#import-pager").hidden = true; + tbody.innerHTML = ""; + if (scanItems.length) { + $("#import-page-info").textContent = ""; + } return; } - const pages = Math.ceil(scanItems.length / IMPORT_PAGE_SIZE); + const pages = Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE); importPage = Math.min(importPage, pages - 1); const start = importPage * IMPORT_PAGE_SIZE; - const pageItems = scanItems.slice(start, start + IMPORT_PAGE_SIZE); + const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE); - tbody.innerHTML = pageItems - .map((item, k) => { - const i = start + k; + tbody.innerHTML = pageIdx + .map((i) => { + const item = scanItems[i]; const opts = missingReqs .filter((r) => r.media_type === item.media_type) .map( @@ -318,13 +486,16 @@ function renderImportTable() { .join(""); return ` - ${esc(item.name)}${item.is_dir ? " 📁" : ""} + + ${esc(item.name)}${item.is_dir ? " 📁" : ""} + ${item.rel_dir ? `
${esc(item.rel_dir)}/
` : ""} + ${esc(item.media_type)} - ${item.suggested_request_id ? item.score : "—"} + ${scoreCell(item)} `; }) .join(""); @@ -335,7 +506,10 @@ function renderImportTable() { b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick))) ); tbody.querySelectorAll("[data-check]").forEach((c) => - c.addEventListener("change", () => (scanItems[c.dataset.check].checked = c.checked)) + c.addEventListener("change", () => { + scanItems[c.dataset.check].checked = c.checked; + updateImportPageInfo(); + }) ); tbody.querySelectorAll("[data-select]").forEach((s) => s.addEventListener("change", () => { @@ -344,19 +518,47 @@ function renderImportTable() { item.checked = !!item.request_id; const check = tbody.querySelector(`[data-check="${s.dataset.select}"]`); if (check) check.checked = item.checked; + updateImportPageInfo(); }) ); - const selected = scanItems.filter((it) => it.checked && it.request_id).length; $("#import-pager").hidden = false; - $("#import-page-info").textContent = - `Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge · ${selected} ausgewählt`; $("#import-prev").disabled = importPage === 0; $("#import-next").disabled = importPage >= pages - 1; + updateImportPageInfo(pages); +} + +function updateImportPageInfo(pages) { + pages = pages ?? Math.max(1, Math.ceil(viewIdx.length / IMPORT_PAGE_SIZE)); + const selected = scanItems.filter((it) => it.checked && it.request_id).length; + const filtered = viewIdx.length !== scanItems.length ? ` (gefiltert: ${viewIdx.length})` : ""; + $("#import-page-info").textContent = + `Seite ${importPage + 1}/${pages} · ${scanItems.length} Einträge${filtered} · ${selected} ausgewählt`; } $("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); }); $("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); }); +$("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); }); +$("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); }); +$("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); }); + +$("#import-select-suggested").addEventListener("click", () => { + applyImportView(); + viewIdx.forEach((i) => { + const item = scanItems[i]; + if (item.suggested_request_id) { + item.request_id = item.request_id || item.suggested_request_id; + item.checked = true; + } + }); + renderImportTable(); +}); + +$("#import-deselect-all").addEventListener("click", () => { + applyImportView(); + viewIdx.forEach((i) => (scanItems[i].checked = false)); + renderImportTable(); +}); // ---- quick request from a scanned file ---- let quickItemIndex = null; @@ -372,7 +574,7 @@ function cleanFileName(name) { function openQuickDialog(i) { quickItemIndex = i; const item = scanItems[i]; - $("#quick-file").textContent = item.name; + $("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name; $("#quick-q").value = cleanFileName(item.name); $("#quick-library").innerHTML = libOptions(item.media_type) || ""; @@ -389,11 +591,14 @@ async function runQuickSearch() { const results = await api( `/api/search?media_type=${item.media_type}&q=${encodeURIComponent($("#quick-q").value)}` ); - if (!results.length) { box.innerHTML = "

Keine Treffer — Suchbegriff anpassen.

"; return; } + if (!results.length) { + box.innerHTML = `

${EMPTY_HINTS[item.media_type]}

`; + return; + } box.innerHTML = results .map( (r, j) => `
- ${r.cover_url ? `` : '
?
'} + ${r.cover_url ? `` : '
?
'}
${esc(r.title)} ${esc(r.authors)} @@ -433,15 +638,20 @@ async function pickQuickResult(r) { year: r.year, series: r.series, cover_url: r.cover_url, }), }); + setLastLib(scanItems[i].media_type, libId); missingReqs.push(req); scanItems[i].request_id = req.id; scanItems[i].checked = true; renderImportTable(); + refreshMissingBadge(); $("#quick-dialog").close(); toast(`„${req.title}" angefragt und verbunden`); } catch (err) { toast(err.message, true); } } +// ---- import execution (batched, with progress) ---- +const IMPORT_BATCH_SIZE = 20; + $("#import-btn").addEventListener("click", async () => { const items = scanItems .filter((item) => item.checked && item.request_id) @@ -452,18 +662,30 @@ $("#import-btn").addEventListener("click", async () => { if (!items.length) { toast("Nichts ausgewählt", true); return; } const btn = $("#import-btn"); btn.disabled = true; - btn.innerHTML = ` Importiere ${items.length} Einträge…`; + const allResults = []; try { - const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items }) }); - $("#import-results").innerHTML = res.results + for (let done = 0; done < items.length; done += IMPORT_BATCH_SIZE) { + btn.innerHTML = + ` Importiere… ${Math.min(done + IMPORT_BATCH_SIZE, items.length)}/${items.length}`; + const batch = items.slice(done, done + IMPORT_BATCH_SIZE); + const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items: batch }) }); + allResults.push(...res.results); + } + const failed = allResults.filter((r) => !r.ok); + $("#import-summary").innerHTML = + `

` + + `${allResults.length - failed.length} importiert` + + (failed.length ? `, ${failed.length} Fehler` : "") + + `

`; + $("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)] .map((r) => r.ok ? `

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

` : `

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

` ) .join(""); - const failed = res.results.filter((r) => !r.ok).length; - toast(failed ? `Import fertig, ${failed} Fehler` : "Import fertig", failed > 0); + toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0); + refreshMissingBadge(); $("#scan-btn").click(); } catch (err) { toast(err.message, true); } finally { @@ -474,3 +696,4 @@ $("#import-btn").addEventListener("click", async () => { // ---- init ---- loadLibraries(); +refreshMissingBadge(); diff --git a/static/index.html b/static/index.html index 12c9ad6..9991ed7 100644 --- a/static/index.html +++ b/static/index.html @@ -4,6 +4,9 @@ wordarr + @@ -11,7 +14,9 @@

📚 wordarr