rework(ui/uix): total update of ui/ux
This commit is contained in:
+267
-44
@@ -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) => `<option value="${l.id}" ${String(l.id) === last ? "selected" : ""}>${esc(l.name)}</option>`)
|
||||
.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) => `<option value="${l.id}">${esc(l.name)}</option>`)
|
||||
.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 = "<p class='muted'>Suche läuft…</p>";
|
||||
const searchBtn = $("#search-btn");
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.innerHTML = '<span class="spinner"></span> Suche…';
|
||||
try {
|
||||
const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`);
|
||||
if (!results.length) { box.innerHTML = "<p class='muted'>Keine Treffer.</p>"; return; }
|
||||
if (!results.length) {
|
||||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
|
||||
return;
|
||||
}
|
||||
const opts = libOptions(type);
|
||||
box.innerHTML = results
|
||||
.map(
|
||||
(r, i) => `<div class="card">
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="">` : '<div class="nocover">?</div>'}
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||||
<div class="card-body">
|
||||
<strong>${esc(r.title)}</strong>
|
||||
<span>${esc(r.authors)}</span>
|
||||
@@ -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 = "<p class='muted'>Nichts hier.</p>"; return; }
|
||||
if (!reqs.length) {
|
||||
box.innerHTML = "<p class='muted'>Nichts hier.</p>";
|
||||
if (status === "missing") $("#missing-bulk-delete").hidden = true;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = reqs
|
||||
.map(
|
||||
(r) => `<div class="card">
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="">` : '<div class="nocover">?</div>'}
|
||||
(r) => `<div class="card clickable" data-detail="${r.id}">
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||||
<div class="card-body">
|
||||
<strong>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}</strong>
|
||||
<span>${esc(r.authors)}</span>
|
||||
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
|
||||
<span class="muted">${esc(r.media_type)} → ${esc(r.library_name)}</span>
|
||||
${status === "imported" ? `<span class="muted mono">${esc(r.imported_path)}</span>` : ""}
|
||||
<div class="row"><button class="danger" data-del-req="${r.id}">Entfernen</button></div>
|
||||
</div>
|
||||
${status === "missing" ? `<input type="checkbox" class="card-check" data-sel-req="${r.id}">` : ""}
|
||||
</div>`
|
||||
)
|
||||
.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) => `<option value="${l.id}" ${l.id === r.library_id ? "selected" : ""}>${esc(l.name)}</option>`)
|
||||
.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 '<td class="score-none">—</td>';
|
||||
const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo";
|
||||
return `<td class="${cls}">${item.score}</td>`;
|
||||
}
|
||||
|
||||
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 `<tr>
|
||||
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
|
||||
<td class="mono">${esc(item.name)}${item.is_dir ? " 📁" : ""}</td>
|
||||
<td class="mono">
|
||||
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
||||
${item.rel_dir ? `<div class="subpath">${esc(item.rel_dir)}/</div>` : ""}
|
||||
</td>
|
||||
<td>${esc(item.media_type)}</td>
|
||||
<td class="row">
|
||||
<select data-select="${i}"><option value="">— überspringen —</option>${opts}</select>
|
||||
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
|
||||
</td>
|
||||
<td>${item.suggested_request_id ? item.score : "—"}</td>
|
||||
${scoreCell(item)}
|
||||
</tr>`;
|
||||
})
|
||||
.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) || "<option value=''>— keine Library —</option>";
|
||||
@@ -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 = "<p class='muted'>Keine Treffer — Suchbegriff anpassen.</p>"; return; }
|
||||
if (!results.length) {
|
||||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = results
|
||||
.map(
|
||||
(r, j) => `<div class="card">
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="">` : '<div class="nocover">?</div>'}
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||||
<div class="card-body">
|
||||
<strong>${esc(r.title)}</strong>
|
||||
<span>${esc(r.authors)}</span>
|
||||
@@ -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 = `<span class="spinner"></span> 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 =
|
||||
`<span class="spinner"></span> 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 =
|
||||
`<p class="${failed.length ? "error" : "ok"}"><strong>` +
|
||||
`${allResults.length - failed.length} importiert` +
|
||||
(failed.length ? `, ${failed.length} Fehler` : "") +
|
||||
`</strong></p>`;
|
||||
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
|
||||
.map((r) =>
|
||||
r.ok
|
||||
? `<p class="ok">✓ ${esc(r.path)} → ${esc(r.dest)}</p>`
|
||||
: `<p class="error">✗ ${esc(r.path)}: ${esc(r.error)}</p>`
|
||||
)
|
||||
.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();
|
||||
|
||||
+94
-29
@@ -4,6 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>wordarr</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📚</text></svg>" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -11,7 +14,9 @@
|
||||
<h1>📚 wordarr</h1>
|
||||
<nav>
|
||||
<button data-view="search" class="active">Suche</button>
|
||||
<button data-view="missing">Missing</button>
|
||||
<button data-view="missing">
|
||||
Missing <span id="missing-badge" class="badge" hidden></span>
|
||||
</button>
|
||||
<button data-view="imported">Importiert</button>
|
||||
<button data-view="import">Import</button>
|
||||
<button data-view="settings">Libraries</button>
|
||||
@@ -26,7 +31,7 @@
|
||||
<option value="comic">Comic/Manga</option>
|
||||
</select>
|
||||
<input id="search-q" placeholder="Titel, Autor oder ISBN…" required />
|
||||
<button type="submit">Suchen</button>
|
||||
<button type="submit" id="search-btn">Suchen</button>
|
||||
<button type="button" id="manual-btn" class="secondary">
|
||||
Manuell anlegen
|
||||
</button>
|
||||
@@ -71,7 +76,7 @@
|
||||
</section>
|
||||
|
||||
<section id="view-missing" hidden>
|
||||
<div class="bar">
|
||||
<div class="bar wrap">
|
||||
<select id="missing-filter-type">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="ebook">Ebook</option>
|
||||
@@ -81,16 +86,23 @@
|
||||
<select id="missing-filter-library">
|
||||
<option value="">Alle Libraries</option>
|
||||
</select>
|
||||
<input id="missing-search" placeholder="Filtern (Titel/Autor/Serie)…" />
|
||||
<button id="missing-bulk-delete" class="danger" hidden>
|
||||
Ausgewählte entfernen
|
||||
</button>
|
||||
</div>
|
||||
<div id="missing-list" class="cards"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-imported" hidden>
|
||||
<div class="bar wrap">
|
||||
<input id="imported-search" placeholder="Filtern (Titel/Autor/Serie)…" />
|
||||
</div>
|
||||
<div id="imported-list" class="cards"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-import" hidden>
|
||||
<div class="bar">
|
||||
<div class="bar wrap">
|
||||
<button id="scan-btn">Download-Ordner scannen</button>
|
||||
<label class="muted"
|
||||
><input type="checkbox" id="split-dirs" /> Ordner als Einzeldateien
|
||||
@@ -98,18 +110,39 @@
|
||||
>
|
||||
<span id="scan-info" class="muted"></span>
|
||||
</div>
|
||||
<table id="import-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Datei/Ordner</th>
|
||||
<th>Typ</th>
|
||||
<th>Zuordnung</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="bar wrap" id="import-toolbar" hidden>
|
||||
<input id="import-filter-text" placeholder="Filtern (Name/Pfad)…" />
|
||||
<select id="import-filter-mode">
|
||||
<option value="all">Alle</option>
|
||||
<option value="suggested">Nur mit Vorschlag</option>
|
||||
<option value="unassigned">Nur ohne Zuordnung</option>
|
||||
</select>
|
||||
<select id="import-sort">
|
||||
<option value="none">Reihenfolge: Scan</option>
|
||||
<option value="score">Score absteigend</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
</select>
|
||||
<button id="import-select-suggested" class="secondary">
|
||||
Alle mit Vorschlag auswählen
|
||||
</button>
|
||||
<button id="import-deselect-all" class="secondary">
|
||||
Alle abwählen
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table id="import-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Datei/Ordner</th>
|
||||
<th>Typ</th>
|
||||
<th>Zuordnung</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bar" id="import-pager" hidden>
|
||||
<button id="import-prev" class="secondary">← Zurück</button>
|
||||
<span id="import-page-info" class="muted"></span>
|
||||
@@ -118,6 +151,7 @@
|
||||
<div class="bar">
|
||||
<button id="import-btn" hidden>Ausgewählte importieren</button>
|
||||
</div>
|
||||
<div id="import-summary"></div>
|
||||
<div id="import-results"></div>
|
||||
|
||||
<dialog id="quick-dialog">
|
||||
@@ -141,19 +175,21 @@
|
||||
|
||||
<section id="view-settings" hidden>
|
||||
<h3>Libraries</h3>
|
||||
<table id="lib-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th>Pfad</th>
|
||||
<th>Ordner-Schema</th>
|
||||
<th>Datei-Schema</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table id="lib-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th>Pfad</th>
|
||||
<th>Ordner-Schema</th>
|
||||
<th>Datei-Schema</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h4>Neue Library</h4>
|
||||
<form id="lib-form" class="bar wrap">
|
||||
<input name="name" placeholder="Name (z.B. Kids)" required />
|
||||
@@ -186,6 +222,35 @@
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="detail-dialog">
|
||||
<form id="detail-form" method="dialog">
|
||||
<div class="detail-head">
|
||||
<img id="detail-cover" alt="" hidden />
|
||||
<div class="nocover" id="detail-nocover">?</div>
|
||||
<div>
|
||||
<h3 id="detail-title"></h3>
|
||||
<p class="muted" id="detail-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
<label>Titel <input name="title" required /></label>
|
||||
<label>Autor(en) <input name="authors" /></label>
|
||||
<label>Sprecher <input name="narrator" /></label>
|
||||
<label>Serie <input name="series" /></label>
|
||||
<div class="row">
|
||||
<label>Band <input name="volume" type="number" min="0" /></label>
|
||||
<label>Jahr <input name="year" type="number" /></label>
|
||||
</div>
|
||||
<label>ISBN/ASIN <input name="external_id" /></label>
|
||||
<label>Library <select name="library_id" id="detail-library"></select></label>
|
||||
<p class="muted mono" id="detail-path"></p>
|
||||
<div class="row">
|
||||
<button value="cancel" class="secondary">Abbrechen</button>
|
||||
<button value="ok" id="detail-save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
+59
-15
@@ -2,53 +2,97 @@
|
||||
:root {
|
||||
--bg: #1a1d24; --panel: #23272f; --border: #333945;
|
||||
--text: #e6e8ec; --muted: #8b93a1; --accent: #4c8fdd; --danger: #c0504e;
|
||||
--ok: #6fbf73; --warn: #d9b44a;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||
header {
|
||||
display: flex; align-items: center; gap: 2rem;
|
||||
display: flex; align-items: center; gap: 2rem; flex-wrap: wrap;
|
||||
padding: 0.8rem 1.2rem; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
h1 { font-size: 1.2rem; }
|
||||
nav { display: flex; gap: 0.4rem; }
|
||||
nav { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
nav button { background: none; border: none; color: var(--muted); padding: 0.5rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.95rem; }
|
||||
nav button.active, nav button:hover { color: var(--text); background: var(--bg); }
|
||||
.badge {
|
||||
background: var(--accent); color: #fff; border-radius: 10px;
|
||||
padding: 0.05rem 0.45rem; font-size: 0.75rem; vertical-align: 0.1em;
|
||||
}
|
||||
main { padding: 1.2rem; max-width: 1100px; margin: 0 auto; }
|
||||
.bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.bar.wrap { flex-wrap: wrap; }
|
||||
input, select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.7rem; font-size: 0.95rem; }
|
||||
input, select {
|
||||
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 0.5rem 0.7rem; font-size: 0.95rem;
|
||||
}
|
||||
input:focus-visible, select:focus-visible, button:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 1px;
|
||||
}
|
||||
input.wide { min-width: 260px; }
|
||||
#search-q { flex: 1; }
|
||||
#search-q, #missing-search, #imported-search, #import-filter-text { flex: 1; min-width: 160px; }
|
||||
button { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 0.5rem 1rem; cursor: pointer; font-size: 0.95rem; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.secondary { background: var(--border); }
|
||||
button.danger { background: var(--danger); padding: 0.3rem 0.7rem; font-size: 0.85rem; }
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
.cards { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.card { display: flex; gap: 0.8rem; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem; }
|
||||
|
||||
/* cards as responsive grid */
|
||||
.cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.card {
|
||||
position: relative; display: flex; gap: 0.8rem;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem;
|
||||
}
|
||||
.card.clickable { cursor: pointer; transition: border-color 0.1s, background 0.1s; }
|
||||
.card.clickable:hover { border-color: var(--accent); background: #262b35; }
|
||||
.card img, .nocover { width: 60px; height: 90px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
|
||||
.nocover { background: var(--bg); display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 1.5rem; }
|
||||
.card-body { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.row { display: flex; gap: 0.5rem; margin-top: 0.4rem; align-items: center; }
|
||||
.card-check { position: absolute; top: 0.6rem; right: 0.6rem; width: 1.1rem; height: 1.1rem; }
|
||||
.row { display: flex; gap: 0.5rem; margin-top: 0.4rem; align-items: center; flex-wrap: wrap; }
|
||||
.muted { color: var(--muted); font-size: 0.88rem; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.subpath { color: var(--muted); font-size: 0.75rem; font-family: ui-monospace, monospace; }
|
||||
|
||||
/* tables */
|
||||
.table-scroll { overflow: auto; max-height: 70vh; margin-bottom: 1rem; border: 1px solid var(--border); border-radius: 6px; }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--panel); }
|
||||
th, td { text-align: left; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--border); font-size: 0.92rem; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
thead th { position: sticky; top: 0; background: var(--panel); color: var(--muted); font-weight: 500; z-index: 1; }
|
||||
td select { max-width: 340px; }
|
||||
.score-hi { color: var(--ok); font-weight: 600; }
|
||||
.score-mid { color: var(--warn); font-weight: 600; }
|
||||
.score-lo { color: var(--danger); font-weight: 600; }
|
||||
.score-none { color: var(--muted); }
|
||||
.volume { width: 90px; }
|
||||
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; }
|
||||
|
||||
/* dialogs */
|
||||
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
|
||||
dialog::backdrop { background: rgba(0,0,0,0.6); }
|
||||
dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); }
|
||||
dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); flex: 1; }
|
||||
.detail-head { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 0.5rem; }
|
||||
.detail-head img, .detail-head .nocover { width: 80px; height: 120px; }
|
||||
|
||||
.spinner {
|
||||
display: inline-block; width: 0.9em; height: 0.9em; vertical-align: -0.12em;
|
||||
border: 2px solid rgba(255,255,255,0.35); border-top-color: #fff;
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#quick-dialog { min-width: 480px; max-width: 640px; }
|
||||
#quick-results { max-height: 50vh; overflow-y: auto; margin-top: 0.6rem; }
|
||||
.ok { color: #6fbf73; }
|
||||
#quick-dialog { min-width: 480px; }
|
||||
#quick-results { display: flex; flex-direction: column; max-height: 50vh; overflow-y: auto; margin-top: 0.6rem; }
|
||||
.ok { color: var(--ok); }
|
||||
.error { color: #e08585; }
|
||||
#toast { position: fixed; bottom: 1.2rem; right: 1.2rem; background: var(--accent); color: #fff; padding: 0.7rem 1.1rem; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||||
#toast { position: fixed; bottom: 1.2rem; right: 1.2rem; background: var(--accent); color: #fff; padding: 0.7rem 1.1rem; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); z-index: 10; }
|
||||
#toast.error { background: var(--danger); }
|
||||
h3, h4 { margin: 0.8rem 0 0.6rem; }
|
||||
#import-summary p { margin: 0.4rem 0; font-size: 1.05rem; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
header { gap: 0.6rem; padding: 0.6rem; }
|
||||
main { padding: 0.8rem; }
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
#quick-dialog { min-width: 0; width: 92vw; }
|
||||
td select { max-width: 200px; }
|
||||
}
|
||||
|
||||
@@ -202,3 +202,26 @@ def test_narrator_in_path_and_tags(client):
|
||||
/ "Harry Potter und der Stein der Weisen.mp3")
|
||||
assert dest.is_file()
|
||||
assert str(ID3(dest)["TCOM"]) == "Rufus Beck"
|
||||
|
||||
|
||||
def test_scan_rel_dir_and_request_update(client):
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "A", "media_type": "audiobook", "root_path": str(client.tmp_path / "a"),
|
||||
}).json()["id"]
|
||||
|
||||
nested = client.downloads / "Sammlung" / "Sonderfolgen" / "CD1"
|
||||
nested.mkdir(parents=True)
|
||||
for i in range(2):
|
||||
(nested / f"t{i}.mp3").write_bytes(b"")
|
||||
item = client.get("/api/import/scan").json()["items"][0]
|
||||
assert item["name"] == "CD1"
|
||||
assert item["rel_dir"] == "Sammlung/Sonderfolgen"
|
||||
|
||||
req = client.post("/api/requests", json={"library_id": lib_id, "title": "X"}).json()
|
||||
updated = client.put(f"/api/requests/{req['id']}", json={
|
||||
"library_id": lib_id, "title": "X", "narrator": "Rufus Beck",
|
||||
"series": "HP", "volume": 1,
|
||||
}).json()
|
||||
assert updated["narrator"] == "Rufus Beck"
|
||||
assert updated["series"] == "HP"
|
||||
assert client.get("/api/requests", params={"status": "missing"}).json()[0]["narrator"] == "Rufus Beck"
|
||||
|
||||
@@ -104,6 +104,22 @@ def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)):
|
||||
return [_out(r) for r in reqs]
|
||||
|
||||
|
||||
@router.put("/{request_id}", response_model=RequestOut)
|
||||
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
|
||||
req = session.get(BookRequest, request_id)
|
||||
if not req:
|
||||
raise HTTPException(404, "request not found")
|
||||
lib = session.get(Library, data.library_id)
|
||||
if not lib:
|
||||
raise HTTPException(404, "library not found")
|
||||
for field, value in data.model_dump().items():
|
||||
setattr(req, field, value)
|
||||
req.media_type = lib.media_type
|
||||
session.commit()
|
||||
session.refresh(req)
|
||||
return _out(req)
|
||||
|
||||
|
||||
@router.delete("/{request_id}")
|
||||
def delete_request(request_id: int, session: Session = Depends(get_session)):
|
||||
req = session.get(BookRequest, request_id)
|
||||
|
||||
@@ -29,12 +29,19 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
|
||||
if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS
|
||||
)
|
||||
|
||||
def rel_dir(d: Path) -> str:
|
||||
try:
|
||||
return str(d.relative_to(root)) if d != root else ""
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
def walk(d: Path):
|
||||
audio = audio_files(d)
|
||||
if len(audio) > 1 and not split_dirs:
|
||||
items.append({
|
||||
"path": str(d),
|
||||
"name": d.name,
|
||||
"rel_dir": rel_dir(d.parent),
|
||||
"media_type": "audiobook",
|
||||
"is_dir": True,
|
||||
"files": [str(f) for f in audio],
|
||||
@@ -51,6 +58,7 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
|
||||
items.append({
|
||||
"path": str(entry),
|
||||
"name": entry.stem,
|
||||
"rel_dir": rel_dir(d),
|
||||
"media_type": mt,
|
||||
"is_dir": False,
|
||||
"files": [str(entry)],
|
||||
|
||||
Reference in New Issue
Block a user