add(ui/ux): german and english ui with a toggle in the navbar
This commit is contained in:
+206
-159
@@ -2,7 +2,7 @@ const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
let libraries = [];
|
||||
const LANGUAGE_NAMES = { german: "Deutsch", english: "Englisch" };
|
||||
const languageName = (code) => (code ? t("lang." + code) : "");
|
||||
const SOURCE_NAMES = {
|
||||
musicbrainz: "MusicBrainz",
|
||||
hardcover: "Hardcover",
|
||||
@@ -30,7 +30,7 @@ async function createRequest(body) {
|
||||
try {
|
||||
return await api("/api/requests", { method: "POST", body: JSON.stringify(body) });
|
||||
} catch (err) {
|
||||
if (err.status === 409 && confirm(err.message + "\nTrotzdem anlegen?")) {
|
||||
if (err.status === 409 && confirm(err.message + "\n" + t("detail.duplicateConfirm"))) {
|
||||
return api("/api/requests?allow_duplicate=true", {
|
||||
method: "POST", body: JSON.stringify(body),
|
||||
});
|
||||
@@ -61,6 +61,14 @@ function getLastLib(type) {
|
||||
function setLastLib(type, id) {
|
||||
try { localStorage.setItem("wordarr.lastLib." + type, String(id)); } catch {}
|
||||
}
|
||||
function noLibraryOption() {
|
||||
return `<option value="">${t("common.noLibrary")}</option>`;
|
||||
}
|
||||
|
||||
function decimalMark() {
|
||||
return lang === "de" ? "," : ".";
|
||||
}
|
||||
|
||||
function libOptions(mediaType) {
|
||||
const last = getLastLib(mediaType);
|
||||
return libraries
|
||||
@@ -80,9 +88,9 @@ async function refreshMissingBadge() {
|
||||
}
|
||||
|
||||
// ---- navigation ----
|
||||
$$("nav button").forEach((btn) =>
|
||||
$$("nav button[data-view]").forEach((btn) =>
|
||||
btn.addEventListener("click", () => {
|
||||
$$("nav button").forEach((b) => b.classList.remove("active"));
|
||||
$$("nav button[data-view]").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
$$("main > section").forEach((s) => (s.hidden = true));
|
||||
$("#view-" + btn.dataset.view).hidden = false;
|
||||
@@ -101,10 +109,10 @@ async function loadLibraries() {
|
||||
(l) => `<tr>
|
||||
<td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td>
|
||||
<td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td>
|
||||
<td>${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"}</td>
|
||||
<td>${l.language ? esc(languageName(l.language)) : "—"}</td>
|
||||
<td class="row">
|
||||
<button class="secondary" data-edit-lib="${l.id}">Bearbeiten</button>
|
||||
<button class="danger" data-del-lib="${l.id}">Löschen</button>
|
||||
<button class="secondary" data-edit-lib="${l.id}">${t("common.edit")}</button>
|
||||
<button class="danger" data-del-lib="${l.id}">${t("common.delete")}</button>
|
||||
</td>
|
||||
</tr>`
|
||||
)
|
||||
@@ -114,7 +122,7 @@ async function loadLibraries() {
|
||||
);
|
||||
tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
|
||||
b.addEventListener("click", async () => {
|
||||
if (!confirm("Library löschen?")) return;
|
||||
if (!confirm(t("lib.confirmDelete"))) return;
|
||||
try {
|
||||
await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" });
|
||||
loadLibraries();
|
||||
@@ -123,7 +131,7 @@ async function loadLibraries() {
|
||||
);
|
||||
const filter = $("#missing-filter-library");
|
||||
filter.innerHTML =
|
||||
'<option value="">Alle Libraries</option>' +
|
||||
`<option value="">${t("list.allLibraries")}</option>` +
|
||||
libraries.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join("");
|
||||
}
|
||||
|
||||
@@ -138,8 +146,8 @@ function startLibraryEdit(id) {
|
||||
form.file_template.value = lib.file_template;
|
||||
form.language.value = lib.language || "";
|
||||
form.dataset.editId = lib.id;
|
||||
$("#lib-form-title").textContent = `Library „${lib.name}“ bearbeiten`;
|
||||
$("#lib-form-submit").textContent = "Speichern";
|
||||
$("#lib-form-title").textContent = t("lib.edit", { name: lib.name });
|
||||
$("#lib-form-submit").textContent = t("common.save");
|
||||
$("#lib-form-cancel").hidden = false;
|
||||
form.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
@@ -148,8 +156,8 @@ function endLibraryEdit() {
|
||||
const form = $("#lib-form");
|
||||
form.reset();
|
||||
delete form.dataset.editId;
|
||||
$("#lib-form-title").textContent = "Neue Library";
|
||||
$("#lib-form-submit").textContent = "Anlegen";
|
||||
$("#lib-form-title").textContent = t("lib.new");
|
||||
$("#lib-form-submit").textContent = t("common.create");
|
||||
$("#lib-form-cancel").hidden = true;
|
||||
}
|
||||
$("#lib-form-cancel").addEventListener("click", endLibraryEdit);
|
||||
@@ -163,10 +171,10 @@ $("#lib-form").addEventListener("submit", async (e) => {
|
||||
try {
|
||||
if (editId) {
|
||||
await api("/api/libraries/" + editId, { method: "PUT", body: JSON.stringify(data) });
|
||||
toast("Library gespeichert — gilt für künftige Importe");
|
||||
toast(t("lib.saved"));
|
||||
} else {
|
||||
await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
|
||||
toast("Library angelegt");
|
||||
toast(t("lib.created"));
|
||||
}
|
||||
endLibraryEdit();
|
||||
loadLibraries();
|
||||
@@ -193,30 +201,51 @@ $("#lib-form").addEventListener("submit", async (e) => {
|
||||
|
||||
// ---- search & request ----
|
||||
const EMPTY_HINTS = {
|
||||
ebook: "Keine Treffer. Tipp: ISBN ohne Bindestriche oder Titel + Autor versuchen — und ggf. „Alle Sprachen“ wählen.",
|
||||
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.",
|
||||
ebook: "search.emptyEbook",
|
||||
audiobook: "search.emptyAudiobook",
|
||||
comic: "search.emptyComic",
|
||||
};
|
||||
|
||||
$("#search-type").addEventListener("change", () => ($("#search-results").innerHTML = ""));
|
||||
let lastSearch = null; // kept so a language switch can redraw the hits
|
||||
|
||||
$("#search-type").addEventListener("change", () => {
|
||||
lastSearch = null;
|
||||
$("#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");
|
||||
const searchBtn = $("#search-btn");
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.innerHTML = '<span class="spinner"></span> Suche…';
|
||||
searchBtn.innerHTML = `<span class="spinner"></span> ${t("search.running")}`;
|
||||
try {
|
||||
const language = $("#search-language").value;
|
||||
const results = await api(
|
||||
`/api/search?media_type=${type}&q=${encodeURIComponent(q)}&language=${language}`
|
||||
);
|
||||
if (!results.length) {
|
||||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[type]}</p>`;
|
||||
return;
|
||||
}
|
||||
lastSearch = { results, type, language };
|
||||
renderSearchResults();
|
||||
} catch (err) {
|
||||
lastSearch = null;
|
||||
$("#search-results").innerHTML = "";
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
searchBtn.disabled = false;
|
||||
searchBtn.textContent = t("common.search");
|
||||
}
|
||||
});
|
||||
|
||||
function renderSearchResults() {
|
||||
if (!lastSearch) return;
|
||||
const { results, type, language } = lastSearch;
|
||||
const box = $("#search-results");
|
||||
if (!results.length) {
|
||||
box.innerHTML = `<p class='muted'>${esc(t(EMPTY_HINTS[type]))}</p>`;
|
||||
return;
|
||||
}
|
||||
{
|
||||
const opts = libOptions(type);
|
||||
box.innerHTML = results
|
||||
.map(
|
||||
@@ -227,12 +256,12 @@ $("#search-form").addEventListener("submit", async (e) => {
|
||||
<span>${esc(r.authors)}</span>
|
||||
${r.narrator ? `<span class="muted">🎙 ${esc(r.narrator)}</span>` : ""}
|
||||
${r.series && r.media_type ? `<span class="muted">📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}</span>` : ""}
|
||||
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(LANGUAGE_NAMES[r.language] || r.language) : ""} ${r.source && r.source !== "audible" ? "· " + esc(SOURCE_NAMES[r.source] || r.source) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
|
||||
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
|
||||
<span class="muted">${r.year ?? ""} ${r.language ? "· " + esc(languageName(r.language)) : ""} ${r.source && r.source !== "audible" ? "· " + esc(SOURCE_NAMES[r.source] || r.source) : ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
|
||||
${type === "comic" ? `<input type="number" min="0" placeholder="${t("common.volume")}" class="volume" data-vol="${i}">` : ""}
|
||||
<div class="row">
|
||||
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
|
||||
<button data-req="${i}">Anfragen</button>
|
||||
${r.series_id ? `<button class="secondary" data-series="${i}">Ganze Serie…</button>` : ""}
|
||||
<select data-lib="${i}">${opts || noLibraryOption()}</select>
|
||||
<button data-req="${i}">${t("common.request")}</button>
|
||||
${r.series_id ? `<button class="secondary" data-series="${i}">${t("search.wholeSeries")}</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
@@ -248,7 +277,7 @@ $("#search-form").addEventListener("submit", async (e) => {
|
||||
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; }
|
||||
if (!libId) { toast(t("common.needLibraryFirst"), true); return; }
|
||||
const volInput = box.querySelector(`[data-vol="${i}"]`);
|
||||
try {
|
||||
await createRequest({
|
||||
@@ -259,20 +288,14 @@ $("#search-form").addEventListener("submit", async (e) => {
|
||||
volume: volInput && volInput.value ? parseInt(volInput.value) : (r.volume ?? null),
|
||||
});
|
||||
setLastLib(type, libId);
|
||||
btn.textContent = "✓ Angefragt";
|
||||
btn.textContent = t("search.requested");
|
||||
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";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- request a whole series ----
|
||||
let seriesEpisodes = [];
|
||||
@@ -291,9 +314,10 @@ function renderSeriesList() {
|
||||
let gap = "";
|
||||
if (e.volume != null && previous != null && e.volume > previous + 1) {
|
||||
const missing = e.volume - previous - 1;
|
||||
const ebook = seriesType === "ebook";
|
||||
const unit = ebook ? (missing > 1 ? "Bände" : "Band") : `Folge${missing > 1 ? "n" : ""}`;
|
||||
gap = `<div class="gap">… ${missing} ${unit} nicht ${ebook ? "bei Hardcover" : "bei Audible"} gefunden (${previous + 1}–${e.volume - 1})</div>`;
|
||||
const key = seriesType === "ebook"
|
||||
? (missing > 1 ? "series.gapVolumes" : "series.gapVolume")
|
||||
: (missing > 1 ? "series.gapEpisodes" : "series.gapEpisode");
|
||||
gap = `<div class="gap">${esc(t(key, { n: missing, from: previous + 1, to: e.volume - 1 }))}</div>`;
|
||||
}
|
||||
if (e.volume != null) previous = e.volume;
|
||||
return `${gap}<label>
|
||||
@@ -315,20 +339,21 @@ function seriesChecked() {
|
||||
function updateSeriesCount() {
|
||||
const n = seriesChecked().length;
|
||||
const btn = $("#series-submit");
|
||||
btn.textContent = n ? `${n} anfragen` : "Anfragen";
|
||||
btn.textContent = n ? t("series.requestN", { n }) : t("common.request");
|
||||
btn.disabled = n === 0;
|
||||
}
|
||||
|
||||
async function openSeriesDialog(result, type = "audiobook", language = "") {
|
||||
const dlg = $("#series-dialog");
|
||||
seriesType = type;
|
||||
const unit = type === "ebook" ? "Bände" : "Folgen";
|
||||
$("#series-title").textContent = result.series || "Serie";
|
||||
$("#series-info").innerHTML = `<span class="spinner"></span> ${unit} werden gesucht…`;
|
||||
const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes");
|
||||
$("#series-title").textContent = result.series || t("series.fallbackTitle");
|
||||
$("#series-info").innerHTML =
|
||||
`<span class="spinner"></span> ${esc(t("series.loading", { unit }))}`;
|
||||
$("#series-list").innerHTML = "";
|
||||
$("#series-from").value = "";
|
||||
$("#series-to").value = "";
|
||||
$("#series-library").innerHTML = libOptions(type) || "<option value=''>— keine Library —</option>";
|
||||
$("#series-library").innerHTML = libOptions(type) || noLibraryOption();
|
||||
$("#series-submit").disabled = true;
|
||||
dlg.showModal();
|
||||
try {
|
||||
@@ -343,11 +368,12 @@ async function openSeriesDialog(result, type = "audiobook", language = "") {
|
||||
}
|
||||
const numbered = seriesEpisodes.filter((e) => e.volume != null);
|
||||
$("#series-info").textContent =
|
||||
`${seriesEpisodes.length} ${unit} gefunden` +
|
||||
(numbered.length ? ` (Nr. ${numbered[0].volume}–${numbered[numbered.length - 1].volume})` : "") +
|
||||
(type === "ebook"
|
||||
? ". Bände ohne Ausgabe in der gewählten Sprache stehen mit ihrem Originaltitel da."
|
||||
: ". Audible hat keine Serien-Abfrage — einzelne Folgen können fehlen.");
|
||||
t("series.found", { n: seriesEpisodes.length, unit }) +
|
||||
(numbered.length
|
||||
? t("series.range", { from: numbered[0].volume,
|
||||
to: numbered[numbered.length - 1].volume })
|
||||
: "") +
|
||||
t(type === "ebook" ? "series.noteEbook" : "series.noteAudiobook");
|
||||
renderSeriesList();
|
||||
}
|
||||
|
||||
@@ -366,7 +392,10 @@ $("#series-cancel").addEventListener("click", () => $("#series-dialog").close())
|
||||
|
||||
$("#series-submit").addEventListener("click", async () => {
|
||||
const libId = $("#series-library").value;
|
||||
if (!libId) { toast(`Erst eine ${seriesType === "ebook" ? "Ebook" : "Audiobook"}-Library anlegen (Tab Libraries)`, true); return; }
|
||||
if (!libId) {
|
||||
toast(t("series.needLibrary", { type: seriesType === "ebook" ? "Ebook" : "Audiobook" }), true);
|
||||
return;
|
||||
}
|
||||
const items = seriesChecked().map((e) => ({
|
||||
title: e.title, authors: e.authors, narrator: e.narrator || "",
|
||||
external_id: e.external_id, year: e.year, series: e.series,
|
||||
@@ -374,7 +403,7 @@ $("#series-submit").addEventListener("click", async () => {
|
||||
}));
|
||||
const btn = $("#series-submit");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Lege an…';
|
||||
btn.innerHTML = `<span class="spinner"></span> ${t("common.creating")}`;
|
||||
try {
|
||||
const res = await api("/api/requests/bulk-items", {
|
||||
method: "POST",
|
||||
@@ -382,8 +411,8 @@ $("#series-submit").addEventListener("click", async () => {
|
||||
});
|
||||
setLastLib(seriesType, libId);
|
||||
toast(
|
||||
`${res.created.length} Anfragen angelegt` +
|
||||
(res.skipped ? `, ${res.skipped} bereits vorhanden` : "")
|
||||
t("series.createdN", { n: res.created.length }) +
|
||||
(res.skipped ? t("series.skippedN", { n: res.skipped }) : "")
|
||||
);
|
||||
$("#series-dialog").close();
|
||||
refreshMissingBadge();
|
||||
@@ -400,7 +429,7 @@ $("#manual-btn").addEventListener("click", () => {
|
||||
const dlg = $("#manual-dialog");
|
||||
const typeSel = dlg.querySelector("[name=media_type]");
|
||||
const updateLibs = () => {
|
||||
$("#manual-library").innerHTML = libOptions(typeSel.value) || "<option value=''>— keine Library —</option>";
|
||||
$("#manual-library").innerHTML = libOptions(typeSel.value) || noLibraryOption();
|
||||
};
|
||||
typeSel.onchange = updateLibs;
|
||||
updateLibs();
|
||||
@@ -410,11 +439,11 @@ $("#manual-btn").addEventListener("click", () => {
|
||||
$("#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; }
|
||||
if (!data.library_id) { toast(t("manual.noLibraryChosen"), true); return; }
|
||||
try {
|
||||
if (data.volume_to) {
|
||||
if (!data.volume) {
|
||||
toast("Für Bulk bitte Start-Band angeben", true);
|
||||
toast(t("manual.needStartVolume"), true);
|
||||
return;
|
||||
}
|
||||
const created = await api("/api/requests/bulk", {
|
||||
@@ -428,7 +457,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
|
||||
year: data.year ? parseInt(data.year) : null,
|
||||
}),
|
||||
});
|
||||
toast(`${created.length} Anfragen angelegt`);
|
||||
toast(t("manual.createdN", { n: created.length }));
|
||||
} else {
|
||||
await createRequest({
|
||||
library_id: parseInt(data.library_id),
|
||||
@@ -438,7 +467,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
|
||||
year: data.year ? parseInt(data.year) : null,
|
||||
volume: data.volume ? parseInt(data.volume) : null,
|
||||
});
|
||||
toast("Anfrage angelegt");
|
||||
toast(t("manual.created"));
|
||||
}
|
||||
setLastLib(data.media_type, data.library_id);
|
||||
e.target.reset();
|
||||
@@ -449,7 +478,7 @@ $("#manual-form").addEventListener("submit", async (e) => {
|
||||
// ---- missing / imported lists ----
|
||||
let currentRequests = { missing: [], imported: [] };
|
||||
|
||||
// view mode (Liste / kleine / große Symbole), shared by both lists
|
||||
// view mode (list / small / large icons), shared by both lists
|
||||
function getViewMode() {
|
||||
try { return localStorage.getItem("wordarr.viewMode") || "small"; } catch { return "small"; }
|
||||
}
|
||||
@@ -494,7 +523,7 @@ function renderRequests(status) {
|
||||
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||||
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
|
||||
if (!reqs.length) {
|
||||
box.innerHTML = "<p class='muted'>Nichts hier.</p>";
|
||||
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
|
||||
bulkBtn.hidden = true;
|
||||
return;
|
||||
}
|
||||
@@ -503,7 +532,7 @@ function renderRequests(status) {
|
||||
(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>
|
||||
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${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>
|
||||
@@ -522,7 +551,7 @@ function renderRequests(status) {
|
||||
const updateBulkBtn = () => {
|
||||
const n = box.querySelectorAll("[data-sel-req]:checked").length;
|
||||
bulkBtn.hidden = n === 0;
|
||||
bulkBtn.textContent = `Ausgewählte entfernen (${n})`;
|
||||
bulkBtn.textContent = t("common.removeSelectedN", { n });
|
||||
};
|
||||
box.querySelectorAll("[data-sel-req]").forEach((c) =>
|
||||
c.addEventListener("change", updateBulkBtn)
|
||||
@@ -534,15 +563,14 @@ async function bulkDeleteRequests(status) {
|
||||
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||||
const ids = [...box.querySelectorAll("[data-sel-req]:checked")].map((c) => c.dataset.selReq);
|
||||
if (!ids.length) return;
|
||||
const hint = status === "imported"
|
||||
? `${ids.length} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.`
|
||||
: `${ids.length} Anfrage(n) entfernen?`;
|
||||
const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
|
||||
{ n: ids.length });
|
||||
if (!confirm(hint)) return;
|
||||
for (const id of ids) {
|
||||
try { await api("/api/requests/" + id, { method: "DELETE" }); }
|
||||
catch (err) { toast(err.message, true); }
|
||||
}
|
||||
toast(`${ids.length} entfernt`);
|
||||
toast(t("list.deletedN", { n: ids.length }));
|
||||
loadRequests(status);
|
||||
}
|
||||
|
||||
@@ -586,7 +614,7 @@ function openDetail(id, status) {
|
||||
.join("");
|
||||
$("#detail-title").textContent = r.title;
|
||||
$("#detail-status").textContent =
|
||||
`${r.media_type} · ${r.status === "missing" ? "fehlt noch" : "importiert"}`;
|
||||
`${r.media_type} · ${t(r.status === "missing" ? "detail.statusMissing" : "detail.statusImported")}`;
|
||||
$("#detail-path").textContent = r.imported_path || "";
|
||||
const img = $("#detail-cover");
|
||||
img.hidden = !r.cover_url;
|
||||
@@ -619,17 +647,17 @@ $("#detail-relocate").addEventListener("click", async () => {
|
||||
if (!detailRequest) return;
|
||||
const btn = $("#detail-relocate");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Lege ab…';
|
||||
btn.innerHTML = `<span class="spinner"></span> ${t("detail.relocating")}`;
|
||||
try {
|
||||
await saveDetail($("#detail-form"));
|
||||
const res = await api(`/api/requests/${detailRequest.id}/relocate`, { method: "POST" });
|
||||
toast(res.moved ? `Neu abgelegt: ${res.dest}` : "Liegt bereits richtig");
|
||||
toast(res.moved ? t("detail.relocated", { dest: res.dest }) : t("detail.alreadyPlaced"));
|
||||
$("#detail-dialog").close();
|
||||
loadRequests(detailRequest.status);
|
||||
} catch (err) { toast(err.message, true); }
|
||||
finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Speichern & neu ablegen";
|
||||
btn.textContent = t("detail.relocate");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -637,17 +665,17 @@ $("#detail-retag").addEventListener("click", async () => {
|
||||
if (!detailRequest) return;
|
||||
const btn = $("#detail-retag");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Tagge…';
|
||||
btn.innerHTML = `<span class="spinner"></span> ${t("detail.retagging")}`;
|
||||
try {
|
||||
await saveDetail($("#detail-form"));
|
||||
const res = await api(`/api/requests/${detailRequest.id}/retag`, { method: "POST" });
|
||||
toast(`${res.files} Datei(en) neu getaggt`);
|
||||
toast(t("detail.retagged", { n: res.files }));
|
||||
$("#detail-dialog").close();
|
||||
loadRequests(detailRequest.status);
|
||||
} catch (err) { toast(err.message, true); }
|
||||
finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Speichern & neu taggen";
|
||||
btn.textContent = t("detail.retag");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -656,7 +684,7 @@ $("#detail-form").addEventListener("submit", async (e) => {
|
||||
if (!detailRequest) return;
|
||||
try {
|
||||
await saveDetail(e.target);
|
||||
toast("Anfrage gespeichert");
|
||||
toast(t("detail.saved"));
|
||||
loadRequests(detailRequest.status);
|
||||
} catch (err) { toast(err.message, true); }
|
||||
});
|
||||
@@ -674,7 +702,7 @@ const IMPORT_PAGE_SIZE = 25;
|
||||
$("#scan-btn").addEventListener("click", async () => {
|
||||
const scanBtn = $("#scan-btn");
|
||||
scanBtn.disabled = true;
|
||||
$("#scan-info").innerHTML = '<span class="spinner"></span> Scanne…';
|
||||
$("#scan-info").innerHTML = `<span class="spinner"></span> ${t("import.scanning")}`;
|
||||
try {
|
||||
const split = $("#split-dirs").checked;
|
||||
const [scan, reqs, done] = await Promise.all([
|
||||
@@ -694,7 +722,8 @@ $("#scan-btn").addEventListener("click", async () => {
|
||||
missingReqs = [...reqs, ...done.filter((r) => orphanedIds.has(r.id))];
|
||||
importedReqs = done.filter((r) => !orphanedIds.has(r.id));
|
||||
importPage = 0;
|
||||
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
|
||||
$("#scan-info").textContent =
|
||||
t("import.candidates", { n: scan.items.length, dir: scan.download_dir });
|
||||
$("#import-toolbar").hidden = scanItems.length === 0;
|
||||
formatOptions();
|
||||
renderImportTable();
|
||||
@@ -737,7 +766,7 @@ function humanSize(bytes) {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let n = bytes, u = 0;
|
||||
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
|
||||
return `${n.toFixed(n < 10 && u > 0 ? 1 : 0).replace(".", ",")} ${units[u]}`;
|
||||
return `${n.toFixed(n < 10 && u > 0 ? 1 : 0).replace(".", decimalMark())} ${units[u]}`;
|
||||
}
|
||||
|
||||
// a Calibre export ships one book in a dozen formats, so the row has to say
|
||||
@@ -753,7 +782,7 @@ function formatOptions() {
|
||||
const all = [...new Set(scanItems.flatMap((it) => it.formats || []))].sort();
|
||||
const current = $("#import-filter-format").value;
|
||||
$("#import-filter-format").innerHTML =
|
||||
'<option value="">Alle Formate</option>' +
|
||||
`<option value="">${t("import.allFormats")}</option>` +
|
||||
all.map((f) => `<option value="${esc(f)}" ${f === current ? "selected" : ""}>.${esc(f)}</option>`).join("");
|
||||
}
|
||||
|
||||
@@ -802,25 +831,25 @@ function renderImportTable() {
|
||||
<td class="mono">
|
||||
${esc(item.name)}${item.is_dir ? " 📁" : ""}
|
||||
${formatTag(item)}
|
||||
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
|
||||
${item.parts ? `<span class="tag">${t("import.parts", { parts: item.parts.length, files: item.files.length })}</span>` : ""}
|
||||
${!item.parts && item.maybe_separate
|
||||
? `<span class="tag warn" title="Die ${item.files.length} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen">${item.files.length} Titel?</span>`
|
||||
? `<span class="tag warn" title="${esc(t("import.maybeSeparateTitle", { n: item.files.length }))}">${t("import.maybeSeparate", { n: item.files.length })}</span>`
|
||||
: ""}
|
||||
${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}
|
||||
${appendOpts ? `<optgroup label="Bereits importiert — Teile anhängen">${appendOpts}</optgroup>` : ""}
|
||||
<select data-select="${i}"><option value="">${t("import.skip")}</option>${opts}
|
||||
${appendOpts ? `<optgroup label="${esc(t("import.appendGroup"))}">${appendOpts}</optgroup>` : ""}
|
||||
</select>
|
||||
<button class="secondary" data-quick="${i}" title="Metadaten suchen und Anfrage direkt verbinden">🔍</button>
|
||||
<button class="secondary" data-quick="${i}" title="${esc(t("import.quickTitle"))}">🔍</button>
|
||||
${item.parts || item.maybe_separate
|
||||
? `<button class="secondary" data-split="${i}" title="${item.parts ? "Zusammenfassung wieder auflösen" : "Ordner in einzelne Titel aufteilen"}">✂️</button>`
|
||||
? `<button class="secondary" data-split="${i}" title="${esc(t(item.parts ? "import.unmerge" : "import.splitDir"))}">✂️</button>`
|
||||
: ""}
|
||||
</td>
|
||||
<td>${libraryCell(item, i)}</td>
|
||||
${item.importState ? importStateCell(item)
|
||||
: item.conflict ? '<td class="state conflict" title="Mehrere Einträge zeigen auf dieselbe Anfrage">⚠ Konflikt</td>'
|
||||
: item.conflict ? `<td class="state conflict" title="${esc(t("import.conflictTitle"))}">${t("import.conflict")}</td>`
|
||||
: scoreCell(item)}
|
||||
</tr>`;
|
||||
})
|
||||
@@ -828,7 +857,9 @@ function renderImportTable() {
|
||||
table.hidden = false;
|
||||
$("#import-btn").hidden = false;
|
||||
const showsState = scanItems.some((it) => it.importState);
|
||||
table.querySelector("thead th:last-child").textContent = showsState ? "Status" : "Score";
|
||||
const head = $("#import-score-head");
|
||||
head.dataset.i18n = showsState ? "common.status" : "common.score";
|
||||
head.textContent = t(head.dataset.i18n);
|
||||
|
||||
tbody.querySelectorAll("[data-quick]").forEach((b) =>
|
||||
b.addEventListener("click", () => openQuickDialog(parseInt(b.dataset.quick)))
|
||||
@@ -850,7 +881,7 @@ function renderImportTable() {
|
||||
}),
|
||||
});
|
||||
Object.assign(req, saved);
|
||||
toast(`„${req.title}" → ${saved.library_name}`);
|
||||
toast(t("import.movedToLibrary", { title: req.title, library: saved.library_name }));
|
||||
} catch (err) {
|
||||
req.library_id = previous;
|
||||
sel.value = String(previous);
|
||||
@@ -893,7 +924,7 @@ function libraryCell(item, i) {
|
||||
.filter((l) => l.media_type === req.media_type)
|
||||
.map((l) => `<option value="${l.id}" ${l.id === req.library_id ? "selected" : ""}>${esc(l.name)}</option>`)
|
||||
.join("");
|
||||
return `<select data-lib-for="${i}" title="Ziel-Library dieser Anfrage ändern">${opts}</select>`;
|
||||
return `<select data-lib-for="${i}" title="${esc(t("import.libraryCellTitle"))}">${opts}</select>`;
|
||||
}
|
||||
|
||||
function requestById(id) {
|
||||
@@ -903,18 +934,19 @@ function requestById(id) {
|
||||
|
||||
function importStateCell(item) {
|
||||
if (item.importState === "running") {
|
||||
return '<td class="state running"><span class="spinner"></span> läuft…</td>';
|
||||
return `<td class="state running"><span class="spinner"></span> ${t("import.running")}</td>`;
|
||||
}
|
||||
if (item.importState === "done") return '<td class="state done">✓ importiert</td>';
|
||||
return `<td class="state failed" title="${esc(item.importError)}">✗ Fehler</td>`;
|
||||
if (item.importState === "done") return `<td class="state done">${t("import.done")}</td>`;
|
||||
return `<td class="state failed" title="${esc(item.importError)}">${t("import.failed")}</td>`;
|
||||
}
|
||||
|
||||
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`;
|
||||
const filtered = viewIdx.length !== scanItems.length ? t("import.filtered", { n: viewIdx.length }) : "";
|
||||
$("#import-page-info").textContent = t("import.pageInfo", {
|
||||
page: importPage + 1, pages, total: scanItems.length, filtered, selected,
|
||||
});
|
||||
}
|
||||
|
||||
$("#import-prev").addEventListener("click", () => { importPage--; renderImportTable(); });
|
||||
@@ -940,15 +972,15 @@ $("#import-select-page").addEventListener("click", () => {
|
||||
applyImportView();
|
||||
const start = importPage * IMPORT_PAGE_SIZE;
|
||||
const pageIdx = viewIdx.slice(start, start + IMPORT_PAGE_SIZE);
|
||||
selectIndices(pageIdx, "dieser Seite");
|
||||
selectIndices(pageIdx, t("import.thisPage"));
|
||||
});
|
||||
|
||||
$("#import-select-all").addEventListener("click", () => {
|
||||
applyImportView();
|
||||
selectIndices(viewIdx, "insgesamt");
|
||||
selectIndices(viewIdx, t("import.inTotal"));
|
||||
});
|
||||
|
||||
// entries without a suggestion are included, "Anfragen aus Ordnernamen" needs them
|
||||
// entries without a suggestion are included, "requests from folder names" needs them
|
||||
function selectIndices(indices, what) {
|
||||
scanItems.forEach((it) => (it.checked = false)); // "take what I see", not "add"
|
||||
indices.forEach((i) => {
|
||||
@@ -960,8 +992,10 @@ function selectIndices(indices, what) {
|
||||
renderImportTable();
|
||||
const withReq = indices.filter((i) => scanItems[i].request_id).length;
|
||||
toast(
|
||||
`${indices.length} Einträge ${what} ausgewählt` +
|
||||
(withReq < indices.length ? `, davon ${indices.length - withReq} ohne Zuordnung` : "")
|
||||
t("import.selectedN", { n: indices.length, what }) +
|
||||
(withReq < indices.length
|
||||
? t("import.selectedWithout", { n: indices.length - withReq })
|
||||
: "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -983,17 +1017,15 @@ function nameToRequest(item) {
|
||||
$("#import-from-names").addEventListener("click", () => {
|
||||
namesTargets = scanItems.filter((it) => it.checked && !it.request_id);
|
||||
if (!namesTargets.length) {
|
||||
toast("Keine ausgewählten Einträge ohne Zuordnung", true);
|
||||
toast(t("names.noneWithout"), true);
|
||||
return;
|
||||
}
|
||||
const type = namesTargets[0].media_type;
|
||||
namesTargets = namesTargets.filter((it) => it.media_type === type);
|
||||
$("#names-intro").textContent =
|
||||
`${namesTargets.length} Einträge ohne Zuordnung (${type}). Titel und Folgennummer ` +
|
||||
"kommen aus dem Ordnernamen; Serie und Autor gelten für alle.";
|
||||
$("#names-intro").textContent = t("names.intro", { n: namesTargets.length, type });
|
||||
$("#names-series").value = lastSeries;
|
||||
$("#names-authors").value = "";
|
||||
$("#names-library").innerHTML = libOptions(type) || "<option value=''>— keine Library —</option>";
|
||||
$("#names-library").innerHTML = libOptions(type) || noLibraryOption();
|
||||
renderNamesPreview();
|
||||
$("#names-dialog").showModal();
|
||||
});
|
||||
@@ -1008,7 +1040,7 @@ function renderNamesPreview() {
|
||||
})
|
||||
.join("") +
|
||||
(namesTargets.length > shown.length
|
||||
? `<div class="gap">… und ${namesTargets.length - shown.length} weitere</div>`
|
||||
? `<div class="gap">${esc(t("names.more", { n: namesTargets.length - shown.length }))}</div>`
|
||||
: "");
|
||||
}
|
||||
|
||||
@@ -1017,12 +1049,12 @@ $("#names-cancel").addEventListener("click", () => $("#names-dialog").close());
|
||||
|
||||
$("#names-submit").addEventListener("click", async () => {
|
||||
const libId = $("#names-library").value;
|
||||
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
|
||||
if (!libId) { toast(t("common.needLibraryFirst"), true); return; }
|
||||
const series = (lastSeries = $("#names-series").value.trim());
|
||||
const authors = $("#names-authors").value.trim();
|
||||
const btn = $("#names-submit");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Lege an…';
|
||||
btn.innerHTML = `<span class="spinner"></span> ${t("common.creating")}`;
|
||||
try {
|
||||
const items = namesTargets.map((it) => ({ ...nameToRequest(it), series, authors }));
|
||||
const res = await api("/api/requests/bulk-items", {
|
||||
@@ -1046,16 +1078,16 @@ $("#names-submit").addEventListener("click", async () => {
|
||||
$("#names-dialog").close();
|
||||
renderImportTable();
|
||||
refreshMissingBadge();
|
||||
toast(
|
||||
`${res.created.length} Anfragen angelegt` +
|
||||
(res.skipped ? `, ${res.skipped} gab es schon` : "") +
|
||||
`, ${linked} Einträge verbunden`
|
||||
);
|
||||
toast(t("names.result", {
|
||||
created: res.created.length,
|
||||
skipped: res.skipped ? t("names.existed", { n: res.skipped }) : "",
|
||||
linked,
|
||||
}));
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Anlegen & verbinden";
|
||||
btn.textContent = t("common.createAndLink");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1075,16 +1107,16 @@ function commonPrefix(names) {
|
||||
$("#import-merge").addEventListener("click", () => {
|
||||
const chosen = scanItems.filter((it) => it.checked);
|
||||
if (chosen.length < 2) {
|
||||
toast("Mindestens zwei Einträge auswählen", true);
|
||||
toast(t("import.needTwo"), true);
|
||||
return;
|
||||
}
|
||||
if (new Set(chosen.map((it) => it.media_type)).size > 1) {
|
||||
toast("Nur Einträge desselben Typs lassen sich zusammenfassen", true);
|
||||
toast(t("import.sameTypeOnly"), true);
|
||||
return;
|
||||
}
|
||||
const parents = new Set(chosen.map((it) => it.rel_dir));
|
||||
if (parents.size > 1) {
|
||||
toast("Die Einträge liegen in verschiedenen Ordnern", true);
|
||||
toast(t("import.differentFolders"), true);
|
||||
return;
|
||||
}
|
||||
// "A - Sphinx", "B - Volk" share no prefix, then the folder carries the title
|
||||
@@ -1110,7 +1142,7 @@ $("#import-merge").addEventListener("click", () => {
|
||||
const first = scanItems.indexOf(chosen[0]);
|
||||
scanItems = scanItems.filter((it) => !chosen.includes(it));
|
||||
scanItems.splice(first, 0, merged);
|
||||
toast(`${chosen.length} Einträge als „${name}" zusammengefasst (${merged.files.length} Dateien)`);
|
||||
toast(t("import.mergedN", { n: chosen.length, name, files: merged.files.length }));
|
||||
renderImportTable();
|
||||
});
|
||||
|
||||
@@ -1134,7 +1166,7 @@ function splitItem(i) {
|
||||
score: null,
|
||||
}));
|
||||
scanItems.splice(i, 1, ...parts);
|
||||
toast(`„${item.name}" in ${parts.length} Einzeltitel aufgeteilt`);
|
||||
toast(t("import.splitN", { name: item.name, n: parts.length }));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -1183,7 +1215,7 @@ function openQuickDialog(i) {
|
||||
const { number, rest } = splitEpisodeNumber(item.name);
|
||||
quickEpisode = number;
|
||||
$("#quick-file").textContent = (item.rel_dir ? item.rel_dir + "/" : "") + item.name;
|
||||
$("#quick-episode").textContent = number != null ? `Folge ${number} — nicht mitgesucht` : "";
|
||||
$("#quick-episode").textContent = number != null ? t("quick.episodeNote", { n: number }) : "";
|
||||
$("#quick-q").value = cleanFileName(rest);
|
||||
quickPicked = null;
|
||||
$("#quick-req-filter").value = "";
|
||||
@@ -1192,10 +1224,9 @@ function openQuickDialog(i) {
|
||||
$("#quick-m-volume").value = number ?? "";
|
||||
$("#quick-m-series").value = lastSeries;
|
||||
$("#quick-based-on").hidden = true;
|
||||
$("#quick-manual-summary").textContent = "Nicht bei Audible? Manuell anlegen";
|
||||
$("#quick-manual-summary").textContent = t("quick.manualSummary");
|
||||
$("#quick-manual").open = false;
|
||||
$("#quick-library").innerHTML =
|
||||
libOptions(item.media_type) || "<option value=''>— keine Library —</option>";
|
||||
$("#quick-library").innerHTML = libOptions(item.media_type) || noLibraryOption();
|
||||
$("#quick-results").innerHTML = "";
|
||||
renderQuickOpenRequests(item);
|
||||
$("#quick-dialog").showModal();
|
||||
@@ -1219,8 +1250,9 @@ function renderQuickOpenRequests(item) {
|
||||
const terms = query.split(/\s+/);
|
||||
matches = open.filter((r) => terms.every((t) => hay(r).includes(t)));
|
||||
heading = matches.length
|
||||
? `${Math.min(matches.length, QUICK_OPEN_LIMIT)} von ${matches.length} Treffern (${open.length} offen)`
|
||||
: `Kein Treffer unter ${open.length} offenen Anfragen`;
|
||||
? t("quick.hits", { shown: Math.min(matches.length, QUICK_OPEN_LIMIT),
|
||||
total: matches.length, open: open.length })
|
||||
: t("quick.noHits", { open: open.length });
|
||||
matches = matches.slice(0, QUICK_OPEN_LIMIT);
|
||||
} else {
|
||||
const tokens = cleanFileName(item.name).toLowerCase().split(" ").filter((t) => t.length > 2);
|
||||
@@ -1230,7 +1262,7 @@ function renderQuickOpenRequests(item) {
|
||||
.sort((a, b) => b.hits - a.hits)
|
||||
.slice(0, 3)
|
||||
.map((m) => m.r);
|
||||
heading = matches.length ? "Passende offene Requests:" : "";
|
||||
heading = matches.length ? t("quick.matchingOpen") : "";
|
||||
}
|
||||
|
||||
box.innerHTML =
|
||||
@@ -1238,8 +1270,8 @@ function renderQuickOpenRequests(item) {
|
||||
matches
|
||||
.map(
|
||||
(r) => `<div class="row open-req">
|
||||
<span>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""} — ${esc(r.authors)} (${esc(r.library_name)})</span>
|
||||
<button class="secondary" data-link-req="${r.id}">Verbinden</button>
|
||||
<span>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""} — ${esc(r.authors)} (${esc(r.library_name)})</span>
|
||||
<button class="secondary" data-link-req="${r.id}">${t("common.link")}</button>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
@@ -1251,7 +1283,7 @@ function renderQuickOpenRequests(item) {
|
||||
scanItems[i].checked = true;
|
||||
renderImportTable();
|
||||
$("#quick-dialog").close();
|
||||
toast("Mit bestehendem Request verbunden");
|
||||
toast(t("quick.linked"));
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1259,7 +1291,7 @@ function renderQuickOpenRequests(item) {
|
||||
async function runQuickSearch() {
|
||||
const item = scanItems[quickItemIndex];
|
||||
const box = $("#quick-results");
|
||||
box.innerHTML = "<p class='muted'>Suche läuft…</p>";
|
||||
box.innerHTML = `<p class='muted'>${t("quick.searching")}</p>`;
|
||||
try {
|
||||
// an "english" library should not offer the German edition
|
||||
const lib = libraries.find((l) => String(l.id) === $("#quick-library").value);
|
||||
@@ -1269,7 +1301,7 @@ async function runQuickSearch() {
|
||||
);
|
||||
quickAutoSearch = false;
|
||||
if (!results.length) {
|
||||
box.innerHTML = `<p class='muted'>${EMPTY_HINTS[item.media_type]}</p>`;
|
||||
box.innerHTML = `<p class='muted'>${esc(t(EMPTY_HINTS[item.media_type]))}</p>`;
|
||||
return;
|
||||
}
|
||||
// one compact row per hit
|
||||
@@ -1280,7 +1312,7 @@ async function runQuickSearch() {
|
||||
r.narrator ? "🎙 " + r.narrator : "",
|
||||
r.series ? `📚 ${r.series}${r.volume != null ? " #" + r.volume : ""}` : "",
|
||||
r.year ?? "",
|
||||
LANGUAGE_NAMES[r.language] || r.language,
|
||||
languageName(r.language),
|
||||
r.source && r.source !== "audible" ? SOURCE_NAMES[r.source] || r.source : "",
|
||||
].filter(Boolean).map(esc).join(" · ");
|
||||
return `<div class="card">
|
||||
@@ -1289,8 +1321,8 @@ async function runQuickSearch() {
|
||||
<strong>${esc(r.title)}</strong>
|
||||
<span class="muted">${meta}</span>
|
||||
</div>
|
||||
<button data-edit="${j}" class="secondary" title="Felder vor dem Anlegen anpassen (z.B. Serie)">✎</button>
|
||||
<button data-pick="${j}" title="Anfrage anlegen und mit diesem Eintrag verbinden">Verbinden</button>
|
||||
<button data-edit="${j}" class="secondary" title="${esc(t("quick.editTitle"))}">✎</button>
|
||||
<button data-pick="${j}" title="${esc(t("quick.pickTitle"))}">${t("common.link")}</button>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -1319,7 +1351,7 @@ $("#quick-close").addEventListener("click", () => $("#quick-dialog").close());
|
||||
// create the request and wire it to the scanned entry
|
||||
async function requestAndConnect(body) {
|
||||
const libId = $("#quick-library").value;
|
||||
if (!libId) { toast("Erst eine Library für diesen Typ anlegen", true); return; }
|
||||
if (!libId) { toast(t("common.needLibraryFirst"), true); return; }
|
||||
const i = quickItemIndex;
|
||||
try {
|
||||
const req = await createRequest({ library_id: parseInt(libId), ...body });
|
||||
@@ -1330,7 +1362,7 @@ async function requestAndConnect(body) {
|
||||
renderImportTable();
|
||||
refreshMissingBadge();
|
||||
$("#quick-dialog").close();
|
||||
toast(`„${req.title}" angefragt und verbunden`);
|
||||
toast(t("quick.requestedAndLinked", { title: req.title }));
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
|
||||
@@ -1350,17 +1382,16 @@ function editBeforeRequest(r) {
|
||||
$("#quick-m-authors").value = r.authors || "";
|
||||
$("#quick-m-series").value = r.series || "";
|
||||
$("#quick-m-volume").value = r.volume ?? quickEpisode ?? "";
|
||||
$("#quick-based-on").textContent =
|
||||
`Übernommen von Audible: „${r.title}" — Sprecher, Cover, Jahr und ASIN bleiben erhalten.`;
|
||||
$("#quick-based-on").textContent = t("quick.basedOn", { title: r.title });
|
||||
$("#quick-based-on").hidden = false;
|
||||
$("#quick-manual-summary").textContent = "Felder anpassen";
|
||||
$("#quick-manual-summary").textContent = t("quick.editSummary");
|
||||
$("#quick-manual").open = true;
|
||||
$("#quick-m-title").focus();
|
||||
}
|
||||
|
||||
$("#quick-m-submit").addEventListener("click", () => {
|
||||
const title = $("#quick-m-title").value.trim();
|
||||
if (!title) { toast("Titel angeben", true); return; }
|
||||
if (!title) { toast(t("quick.needTitle"), true); return; }
|
||||
const volume = $("#quick-m-volume").value;
|
||||
const base = quickPicked
|
||||
? { narrator: quickPicked.narrator || "", external_id: quickPicked.external_id,
|
||||
@@ -1408,10 +1439,7 @@ function buildImportGroups(groups) {
|
||||
// either a multi-part title (merge) or a mismatch (skip), decided once
|
||||
function askAboutSharedRequests(shared) {
|
||||
const dlg = $("#conflict-dialog");
|
||||
$("#conflict-intro").textContent =
|
||||
`${shared.length} Anfrage(n) sind mehreren Einträgen zugeordnet. Gehören die Einträge ` +
|
||||
"zusammen (Mehrteiler), lassen sie sich als ein Hörbuch importieren — sonst " +
|
||||
"überspringst du sie, korrigierst die Zuordnung und importierst sie später.";
|
||||
$("#conflict-intro").textContent = t("conflict.intro", { n: shared.length });
|
||||
$("#conflict-list").innerHTML = shared
|
||||
.map((group) => {
|
||||
const req = missingReqs.find((r) => r.id === group[0].request_id);
|
||||
@@ -1469,13 +1497,11 @@ function sharedParent(group) {
|
||||
|
||||
$("#import-btn").addEventListener("click", async () => {
|
||||
const chosen = scanItems.filter((item) => item.checked && item.request_id);
|
||||
if (!chosen.length) { toast("Nichts ausgewählt", true); return; }
|
||||
if (!chosen.length) { toast(t("import.nothingSelected"), true); return; }
|
||||
const groups = await collapseSharedRequests(chosen);
|
||||
if (!groups) return;
|
||||
if (!groups.length) {
|
||||
$("#import-summary").innerHTML =
|
||||
`<p class="error">Nichts importiert: alle ausgewählten Einträge waren mehreren ` +
|
||||
`Anfragen zugeordnet — Filter „Nur Konflikte" zeigt sie.</p>`;
|
||||
$("#import-summary").innerHTML = `<p class="error">${esc(t("import.nothingImported"))}</p>`;
|
||||
renderImportTable();
|
||||
return;
|
||||
}
|
||||
@@ -1520,12 +1546,11 @@ $("#import-btn").addEventListener("click", async () => {
|
||||
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` : "") +
|
||||
esc(t("import.summary", { n: allResults.length - failed.length })) +
|
||||
(failed.length ? esc(t("import.summaryFailed", { n: failed.length })) : "") +
|
||||
`</strong></p>` +
|
||||
(skippedConflicts
|
||||
? `<p class="error">${skippedConflicts} Anfrage(n) waren mehreren Einträgen ` +
|
||||
`zugeordnet und wurden übersprungen — Filter „Nur Konflikte" zeigt sie.</p>`
|
||||
? `<p class="error">${esc(t("import.summarySkipped", { n: skippedConflicts }))}</p>`
|
||||
: "");
|
||||
$("#import-results").innerHTML = [...failed, ...allResults.filter((r) => r.ok)]
|
||||
.map((r) =>
|
||||
@@ -1534,7 +1559,8 @@ $("#import-btn").addEventListener("click", async () => {
|
||||
: `<p class="error">✗ ${esc(r.path)}: ${esc(r.error)}</p>`
|
||||
)
|
||||
.join("");
|
||||
toast(failed.length ? `Import fertig, ${failed.length} Fehler` : "Import fertig", failed.length > 0);
|
||||
toast(failed.length ? t("import.finishedFailed", { n: failed.length }) : t("import.finished"),
|
||||
failed.length > 0);
|
||||
refreshMissingBadge();
|
||||
clearTimeout(cleanupTimer);
|
||||
cleanupTimer = setTimeout(() => {
|
||||
@@ -1545,14 +1571,13 @@ $("#import-btn").addEventListener("click", async () => {
|
||||
importPage = 0;
|
||||
setImportProgress(0, 0);
|
||||
renderImportTable();
|
||||
$("#scan-info").textContent =
|
||||
`${scanItems.length} Kandidat(en) übrig — für den aktuellen Stand erneut scannen`;
|
||||
$("#scan-info").textContent = t("import.remaining", { n: scanItems.length });
|
||||
}
|
||||
}, IMPORT_DONE_CLEANUP_MS);
|
||||
// the list stays: rescanning would wipe the feedback
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Ausgewählte importieren";
|
||||
btn.textContent = t("import.run");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1563,7 +1588,7 @@ function setImportProgress(done, total) {
|
||||
box.hidden = done < total;
|
||||
if (done >= total) {
|
||||
box.querySelector("progress").value = total;
|
||||
box.querySelector("span").textContent = `${total}/${total} · fertig`;
|
||||
box.querySelector("span").textContent = t("import.progressDone", { total });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1575,6 +1600,28 @@ function setImportProgress(done, total) {
|
||||
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
|
||||
}
|
||||
|
||||
// ---- language ----
|
||||
function updateLangButton() {
|
||||
$("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
|
||||
}
|
||||
|
||||
$("#lang-toggle").addEventListener("click", () => setLang(lang === "de" ? "en" : "de"));
|
||||
|
||||
// the static labels are handled by applyI18n, everything rendered from data is not
|
||||
function onLangChange() {
|
||||
updateLangButton();
|
||||
renderSearchResults();
|
||||
renderRequests("missing");
|
||||
renderRequests("imported");
|
||||
if (libraries.length) loadLibraries();
|
||||
const editId = $("#lib-form").dataset.editId;
|
||||
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
||||
if (scanItems.length) renderImportTable();
|
||||
}
|
||||
|
||||
// ---- init ----
|
||||
applyI18n();
|
||||
updateLangButton();
|
||||
endLibraryEdit();
|
||||
loadLibraries();
|
||||
refreshMissingBadge();
|
||||
|
||||
Reference in New Issue
Block a user