diff --git a/README.md b/README.md index 7401e24..2eeb10b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ the same mount, otherwise moving turns into copy + delete. | `WORDARR_HARDCOVER_TOKEN` | – | Hardcover API token, the best ebook source; without it Hardcover is skipped | | `WORDARR_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry | +The UI speaks German and English; the button next to the navigation switches +between them and remembers the choice. Without one, the browser language decides. + ## Usage 1. **Libraries** – create one per target folder with a media type, a naming diff --git a/static/app.js b/static/app.js index 2508867..5846d1a 100644 --- a/static/app.js +++ b/static/app.js @@ -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 ``; +} + +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) => ` ${esc(l.name)}${esc(l.media_type)}${esc(l.root_path)} ${esc(l.folder_template)}${esc(l.file_template)} - ${l.language ? esc(LANGUAGE_NAMES[l.language] || l.language) : "—"} + ${l.language ? esc(languageName(l.language)) : "—"} - - + + ` ) @@ -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 = - '' + + `` + libraries.map((l) => ``).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 = ' Suche…'; + searchBtn.innerHTML = ` ${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 = `

${EMPTY_HINTS[type]}

`; - 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 = `

${esc(t(EMPTY_HINTS[type]))}

`; + return; + } + { const opts = libOptions(type); box.innerHTML = results .map( @@ -227,12 +256,12 @@ $("#search-form").addEventListener("submit", async (e) => { ${esc(r.authors)} ${r.narrator ? `🎙 ${esc(r.narrator)}` : ""} ${r.series && r.media_type ? `📚 ${esc(r.series)}${r.volume != null ? " #" + r.volume : ""}` : ""} - ${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) : ""} - ${type === "comic" ? `` : ""} + ${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) : ""} + ${type === "comic" ? `` : ""}
- - - ${r.series_id ? `` : ""} + + + ${r.series_id ? `` : ""}
` @@ -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 = `
… ${missing} ${unit} nicht ${ebook ? "bei Hardcover" : "bei Audible"} gefunden (${previous + 1}–${e.volume - 1})
`; + const key = seriesType === "ebook" + ? (missing > 1 ? "series.gapVolumes" : "series.gapVolume") + : (missing > 1 ? "series.gapEpisodes" : "series.gapEpisode"); + gap = `
${esc(t(key, { n: missing, from: previous + 1, to: e.volume - 1 }))}
`; } if (e.volume != null) previous = e.volume; return `${gap}