Feature/ui routing and states #1

Merged
admin merged 2 commits from feature/ui-routing-and-states into main 2026-10-25 19:45:51 +01:00
6 changed files with 70 additions and 16 deletions

No files matched your search

+39 -10
View File
@@ -39,12 +39,16 @@ async function createRequest(body) {
} }
} }
let toastTimer;
function toast(msg, isError = false) { function toast(msg, isError = false) {
const t = $("#toast"); const t = $("#toast");
t.textContent = msg; t.textContent = msg;
t.className = isError ? "error" : ""; t.className = isError ? "error" : "";
t.hidden = false; 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(...)}") // quotes included: the result also lands inside attributes (value="${esc(...)}")
@@ -88,17 +92,26 @@ async function refreshMissingBadge() {
} }
// ---- navigation ---- // ---- 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) => $$("nav button[data-view]").forEach((btn) =>
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
$$("nav button[data-view]").forEach((b) => b.classList.remove("active")); // same hash fires no hashchange, but a click should still reload the list
btn.classList.add("active"); if (location.hash === "#" + btn.dataset.view) showView();
$$("main > section").forEach((s) => (s.hidden = true)); else location.hash = btn.dataset.view;
$("#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();
}) })
); );
window.addEventListener("hashchange", showView);
// ---- libraries ---- // ---- libraries ----
async function loadLibraries() { async function loadLibraries() {
@@ -513,7 +526,15 @@ async function loadRequests(status) {
if (t) params.set("media_type", t); if (t) params.set("media_type", t);
if (l) params.set("library_id", l); 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 = `<p class="muted"><span class="spinner"></span> ${t("common.loading")}</p>`;
}
try {
currentRequests[status] = await api("/api/requests?" + params);
} catch (err) {
toast(err.message, true);
}
renderRequests(status); renderRequests(status);
if (status === "missing") refreshMissingBadge(); if (status === "missing") refreshMissingBadge();
} }
@@ -530,7 +551,7 @@ function renderRequests(status) {
} }
box.innerHTML = reqs box.innerHTML = reqs
.map( .map(
(r) => `<div class="card clickable" data-detail="${r.id}"> (r) => `<div class="card clickable" data-detail="${r.id}" tabindex="0">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'} ${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body"> <div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong> <strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong>
@@ -549,6 +570,13 @@ function renderRequests(status) {
openDetail(parseInt(card.dataset.detail), 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 updateBulkBtn = () => {
const n = box.querySelectorAll("[data-sel-req]:checked").length; const n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0; bulkBtn.hidden = n === 0;
@@ -1754,3 +1782,4 @@ endLibraryEdit();
loadLibraries(); loadLibraries();
refreshMissingBadge(); refreshMissingBadge();
showVersion(); showVersion();
showView();
+2
View File
@@ -33,6 +33,7 @@ const STRINGS = {
"common.status": "Status", "common.status": "Status",
"common.noLibrary": "— keine Library —", "common.noLibrary": "— keine Library —",
"common.nothingHere": "Nichts hier.", "common.nothingHere": "Nichts hier.",
"common.loading": "Wird geladen…",
"common.optionalForAll": "optional, für alle", "common.optionalForAll": "optional, für alle",
"common.optional": "optional", "common.optional": "optional",
"common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)", "common.needLibraryFirst": "Erst eine Library für diesen Typ anlegen (Tab Libraries)",
@@ -256,6 +257,7 @@ const STRINGS = {
"common.status": "Status", "common.status": "Status",
"common.noLibrary": "— no library —", "common.noLibrary": "— no library —",
"common.nothingHere": "Nothing here.", "common.nothingHere": "Nothing here.",
"common.loading": "Loading…",
"common.optionalForAll": "optional, for all", "common.optionalForAll": "optional, for all",
"common.optional": "optional", "common.optional": "optional",
"common.needLibraryFirst": "Create a library for this type first (Libraries tab)", "common.needLibraryFirst": "Create a library for this type first (Libraries tab)",
+1
View File
@@ -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 { cursor: pointer; transition: border-color 0.1s, background 0.1s; }
.card.clickable:hover { border-color: var(--accent); background: #262b35; } .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; } .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; } .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; } .card-body { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
+24
View File
@@ -159,3 +159,27 @@ def test_the_header_shows_the_running_version(page):
stamp = page.locator("#app-version").text_content() stamp = page.locator("#app-version").text_content()
assert stamp.startswith("v") assert stamp.startswith("v")
assert len(stamp) > 3 # version plus the build stamp behind it 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()
+2 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,8 +21,7 @@ class LibraryIn(BaseModel):
class LibraryOut(LibraryIn): class LibraryOut(LibraryIn):
id: int id: int
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
def _validate(data: LibraryIn): def _validate(data: LibraryIn):
+2 -3
View File
@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -34,8 +34,7 @@ class RequestOut(RequestIn):
imported_path: str imported_path: str
library_name: str = "" library_name: str = ""
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
def _out(r: BookRequest) -> RequestOut: def _out(r: BookRequest) -> RequestOut: