diff --git a/static/app.js b/static/app.js index 0249997..63096e9 100644 --- a/static/app.js +++ b/static/app.js @@ -39,12 +39,16 @@ async function createRequest(body) { } } +let toastTimer; + function toast(msg, isError = false) { const t = $("#toast"); t.textContent = msg; t.className = isError ? "error" : ""; t.hidden = false; - setTimeout(() => (t.hidden = true), 4000); + // an older toast's timer would otherwise hide this one early + clearTimeout(toastTimer); + toastTimer = setTimeout(() => (t.hidden = true), 4000); } // quotes included: the result also lands inside attributes (value="${esc(...)}") @@ -88,17 +92,26 @@ async function refreshMissingBadge() { } // ---- navigation ---- +// the view lives in the hash so reload, back button and links keep it +const VIEWS = ["search", "missing", "imported", "import", "settings"]; + +function showView() { + const hash = location.hash.slice(1); + const name = VIEWS.includes(hash) ? hash : "search"; + $$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); + $$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name)); + if (name === "missing" || name === "imported") loadRequests(name); + if (name === "settings") loadLibraries().catch((err) => toast(err.message, true)); +} + $$("nav button[data-view]").forEach((btn) => btn.addEventListener("click", () => { - $$("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; - if (btn.dataset.view === "missing") loadRequests("missing"); - if (btn.dataset.view === "imported") loadRequests("imported"); - if (btn.dataset.view === "settings") loadLibraries(); + // same hash fires no hashchange, but a click should still reload the list + if (location.hash === "#" + btn.dataset.view) showView(); + else location.hash = btn.dataset.view; }) ); +window.addEventListener("hashchange", showView); // ---- libraries ---- async function loadLibraries() { @@ -513,7 +526,15 @@ async function loadRequests(status) { if (t) params.set("media_type", t); if (l) params.set("library_id", l); } - currentRequests[status] = await api("/api/requests?" + params); + const box = status === "missing" ? $("#missing-list") : $("#imported-list"); + if (!currentRequests[status].length) { + box.innerHTML = `

${t("common.loading")}

`; + } + try { + currentRequests[status] = await api("/api/requests?" + params); + } catch (err) { + toast(err.message, true); + } renderRequests(status); if (status === "missing") refreshMissingBadge(); } @@ -530,7 +551,7 @@ function renderRequests(status) { } box.innerHTML = reqs .map( - (r) => `
+ (r) => `
${r.cover_url ? `` : '
?
'}
${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""} @@ -549,6 +570,13 @@ function renderRequests(status) { openDetail(parseInt(card.dataset.detail), status); }) ); + box.querySelectorAll("[data-detail]").forEach((card) => + card.addEventListener("keydown", (ev) => { + if (ev.target !== card || (ev.key !== "Enter" && ev.key !== " ")) return; + ev.preventDefault(); + openDetail(parseInt(card.dataset.detail), status); + }) + ); const updateBulkBtn = () => { const n = box.querySelectorAll("[data-sel-req]:checked").length; bulkBtn.hidden = n === 0; @@ -1754,3 +1782,4 @@ endLibraryEdit(); loadLibraries(); refreshMissingBadge(); showVersion(); +showView(); diff --git a/static/i18n.js b/static/i18n.js index 217fffc..5920450 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -33,6 +33,7 @@ const STRINGS = { "common.status": "Status", "common.noLibrary": "— keine Library —", "common.nothingHere": "Nichts hier.", + "common.loading": "Wird geladen…", "common.optionalForAll": "optional, für alle", "common.optional": "optional", "common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)", @@ -256,6 +257,7 @@ const STRINGS = { "common.status": "Status", "common.noLibrary": "— no library —", "common.nothingHere": "Nothing here.", + "common.loading": "Loading…", "common.optionalForAll": "optional, for all", "common.optional": "optional", "common.needLibraryFirst": "Create a library for this type first (Libraries tab)", diff --git a/static/style.css b/static/style.css index 0fe2972..be3489f 100644 --- a/static/style.css +++ b/static/style.css @@ -48,6 +48,7 @@ button:disabled { opacity: 0.6; cursor: default; } } .card.clickable { cursor: pointer; transition: border-color 0.1s, background 0.1s; } .card.clickable:hover { border-color: var(--accent); background: #262b35; } +.card.clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .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; } diff --git a/tests/test_ui_dialogs.py b/tests/test_ui_dialogs.py index 2889fb2..92b2fd8 100644 --- a/tests/test_ui_dialogs.py +++ b/tests/test_ui_dialogs.py @@ -159,3 +159,27 @@ def test_the_header_shows_the_running_version(page): stamp = page.locator("#app-version").text_content() assert stamp.startswith("v") assert len(stamp) > 3 # version plus the build stamp behind it + + +def test_the_view_survives_reload_and_back(page, server): + page.click("nav button[data-view=missing]") + assert page.url.endswith("#missing") + page.reload(wait_until="networkidle") + assert page.locator("#view-missing").is_visible() + assert not page.locator("#view-search").is_visible() + page.click("nav button[data-view=settings]") + page.go_back() + assert page.locator("#view-missing").is_visible() + assert page.locator("nav button[data-view=missing]").get_attribute("class") == "active" + + +def test_a_card_opens_from_the_keyboard(page, server, tmp_path): + lib = page.request.post(f"{server}/api/libraries", data={ + "name": "Tastatur", "media_type": "ebook", "root_path": str(tmp_path)}).json() + page.request.post(f"{server}/api/requests", data={ + "title": "Zyxw Tastaturprobe", "library_id": lib["id"]}) + page.click("nav button[data-view=missing]") + card = page.locator("[data-detail]", has_text="Zyxw Tastaturprobe") + card.focus() + page.keyboard.press("Enter") + assert page.locator("#detail-dialog").is_visible() diff --git a/wordarr/api/libraries.py b/wordarr/api/libraries.py index f996167..21bb8c9 100644 --- a/wordarr/api/libraries.py +++ b/wordarr/api/libraries.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.orm import Session @@ -21,8 +21,7 @@ class LibraryIn(BaseModel): class LibraryOut(LibraryIn): id: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) def _validate(data: LibraryIn): diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py index ba1b039..49e8b9e 100644 --- a/wordarr/api/requests.py +++ b/wordarr/api/requests.py @@ -1,7 +1,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.orm import Session @@ -34,8 +34,7 @@ class RequestOut(RequestIn): imported_path: str library_name: str = "" - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) def _out(r: BookRequest) -> RequestOut: