From 2543d44860fa30ba4538103afdd717db1a0ad7e2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 21:17:09 +0200 Subject: [PATCH 01/31] fix(messages): the welcome reads in the reader's language, not the one it was created in E31 S1: the welcome is rendered once, at account creation, so an account that switched language kept it in the old one (the owner's, English under a Hungarian UI). A stored copy is recognised by its opening line (derived from WELCOME) and served in the reader's language; other system messages stay as stored. --- backend/app/routes/messages.py | 27 ++++++++++--- backend/tests/test_messages_welcome.py | 53 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_messages_welcome.py diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 342bda78..d0e9a7b0 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -94,13 +94,30 @@ def _system_partner() -> dict: return {"id": SYSTEM_PARTNER_ID, "name": SYSTEM_NAME, "avatar_url": None} -def _serialize_msg(m: Message) -> dict: +# The welcome's opening line in every language — how a stored copy is recognised. It was rendered +# once, in the language of the day it was created, so an account that later switched language kept +# reading it in the old one (E31 S1: the owner's, in English). Recognised here, it is served in the +# reader's language instead. Derived from WELCOME itself, so only a change to an OPENING line would +# orphan the copies already stored. +_WELCOME_OPENINGS = {text.partition("\n")[0] for text in WELCOME.values()} + + +def _system_body(body: str | None, lang: str | None) -> str | None: + """A system message's text for a reader of `lang`: the welcome in their language, any other as + stored.""" + if body is None or lang is None or body.partition("\n")[0] not in _WELCOME_OPENINGS: + return body + return WELCOME.get(lang, WELCOME["en"]) + + +def _serialize_msg(m: Message, lang: str | None = None) -> dict: + """`lang`: the reader's language, for a system message's text (a user message is ciphertext).""" return { "id": m.id, "kind": m.kind, "sender_id": m.sender_id, "recipient_id": m.recipient_id, - "body": m.body, # only set for system messages + "body": _system_body(m.body, lang) if m.kind == "system" else m.body, "ciphertext": m.ciphertext, "iv": m.iv, "read_at": iso(m.read_at), @@ -124,7 +141,7 @@ def ensure_welcome(db: Session, user: User) -> None: db.add(m) db.commit() db.refresh(m) - manager.push(user.id, {"type": "message", "message": _serialize_msg(m)}) + manager.push(user.id, {"type": "message", "message": _serialize_msg(m, lang_for_user(user))}) # --- E2EE key directory / blob store ----------------------------------------- @@ -313,7 +330,7 @@ def list_conversations(user: User = Depends(require_human), db: Session = Depend items.append( { "partner": partner, - "last_message": _serialize_msg(m), + "last_message": _serialize_msg(m, lang_for_user(user)), "unread": int(unread.get(pid, 0)), } ) @@ -366,7 +383,7 @@ def get_thread( # refreshes live instead of waiting for the next 20s poll. The client re-fetches the # count on this signal, so no number is carried here. manager.push(user.id, {"type": "unread"}) - return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]} + return {"partner": partner, "items": [_serialize_msg(m, lang_for_user(user)) for m in msgs]} @router.post("") diff --git a/backend/tests/test_messages_welcome.py b/backend/tests/test_messages_welcome.py new file mode 100644 index 00000000..8d279450 --- /dev/null +++ b/backend/tests/test_messages_welcome.py @@ -0,0 +1,53 @@ +"""E31 S1: the welcome message is rendered once, when the account is created, in the language of that day. +An account that switched language afterwards kept reading it in the old one (the owner's: English under a +Hungarian UI). It is served in the reader's language now; any other system message stays as stored. +Needs the DB.""" + +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import Message, User +from app.routes.messages import WELCOME + + +def _client(db, user, monkeypatch, override_dep) -> TestClient: + monkeypatch.setattr(state, "_configured_cache", True) + override_dep(get_db, db.info["request"]) + override_dep(current_user, lambda: user) + return TestClient(app) + + +def _user_with_english_welcome(db) -> User: + """An account whose welcome was written in English, then switched to Hungarian.""" + user = User(email="welcome-hu@example.com", preferences={"language": "hu"}) + db.add(user) + db.flush() + db.add(Message(kind="system", sender_id=None, recipient_id=user.id, body=WELCOME["en"])) + db.commit() + return user + + +def test_the_welcome_is_read_in_the_readers_language(db, monkeypatch, override_dep): + user = _user_with_english_welcome(db) + client = _client(db, user, monkeypatch, override_dep) + + thread = client.get("/api/messages/conversations/0").json()["items"] + assert [m["body"] for m in thread] == [WELCOME["hu"]] + + convs = client.get("/api/messages/conversations").json()["items"] + assert [c["last_message"]["body"] for c in convs if c["partner"]["id"] == 0] == [WELCOME["hu"]] + + +def test_another_system_message_is_served_as_stored(db, monkeypatch, override_dep): + user = _user_with_english_welcome(db) + db.add( + Message(kind="system", sender_id=None, recipient_id=user.id, body="Maintenance tonight.") + ) + db.commit() + client = _client(db, user, monkeypatch, override_dep) + + bodies = [m["body"] for m in client.get("/api/messages/conversations/0").json()["items"]] + assert bodies == [WELCOME["hu"], "Maintenance tonight."] From ec22eefd31ef0583b0ba306a1feae26b1352a67f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 21:17:20 +0200 Subject: [PATCH 02/31] =?UTF-8?q?fix(ui):=20E31=20S2=20=E2=80=94=20the=20t?= =?UTF-8?q?ab=20names=20the=20object,=20the=20channel=20header=20wraps,=20?= =?UTF-8?q?the=20player=20fits=20a=20short=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The browser tab names the place and the object: PageHeading reports the view's own title (lib/documentTitle), so a channel, playlist or any Plex view reads " · · Siftlode" however it was opened; the Feed is named too; a season adds its show (tabTitle). - The channel page's tab + action row wraps and lets the tag lane fold into "+N": at 1024 it ran past the edge and the page scrolled sideways. - The YouTube player card's width is capped from the window height less the chrome measured under the video: at 133% zoom (685px) it cut its channel row. - Plex certificate words (Not Rated, Approved, Passed) are named through the vocabulary on the info badge, show page, collection, filter and chips. - DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log). --- docs/DESIGN.md | 10 ++-- frontend/src/components/ChannelPage.tsx | 24 ++++---- .../NavigationProvider.dom.test.tsx | 38 +++++++++++- .../src/components/NavigationProvider.tsx | 21 +++---- .../src/components/PlayerModal.dom.test.tsx | 25 ++++++++ frontend/src/components/PlayerModal.tsx | 38 +++++++++++- frontend/src/components/Playlists.tsx | 5 +- .../src/components/PlexCollectionView.tsx | 3 +- frontend/src/components/PlexInfo.dom.test.tsx | 7 ++- frontend/src/components/PlexInfo.tsx | 4 +- frontend/src/components/PlexSidebar.tsx | 6 +- frontend/src/components/plexViews.tsx | 7 ++- frontend/src/components/ui/Headings.tsx | 15 +++-- .../usePlexActiveFilters.dom.test.tsx | 8 +++ .../src/components/usePlexActiveFilters.ts | 5 +- frontend/src/i18n/locales/en/vocab.json | 6 ++ frontend/src/i18n/locales/hu/vocab.json | 6 ++ frontend/src/lib/documentTitle.ts | 58 +++++++++++++++++++ frontend/src/lib/vocab.dom.test.ts | 9 +++ frontend/src/lib/vocab.ts | 16 +++-- 20 files changed, 265 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/documentTitle.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2350d0d1..4a85a08a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -39,8 +39,8 @@ runs this check before it is called done (E28 S3g): 4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's. 5. **Judged as a whole** (E30 S6). For each screenshot, one written line on its composition: where the empty - space is, what hugs one side, what the eye lands on first. Then the screen at 200% browser zoom and at - the largest text size. Both are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6). + space is, what hugs one side, what the eye lands on first. Then the screen at 125% and 133% browser + zoom and at the largest text size. All are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6). A rule that holds is not the same as a screen that works: the person page passed every rule above with half its panel empty. @@ -465,8 +465,9 @@ of a full-width panel and nothing on the right. (TMDB, YouTube) is fetched ahead of time by a job, not on the click, so a page never waits on it. Late content keeps its place (B8). [Nielsen #1, RAIL] - **Zoom and text size are first-class.** The owner reads with the browser zoomed and a large text size. - Every screen holds at 200% browser zoom on a desktop window and at the largest Settings text size at - 1024 wide: nothing clipped or overlapping, no sideways page scroll, every control reachable and at + Every screen holds at 125% and 133% browser zoom on a 1080p desktop window (what the owner uses: the + layout of a 1536 × 729 and a 1444 × 685 viewport) and at the largest Settings text size at 1024 wide: + nothing clipped or overlapping, no sideways page scroll, every control reachable and at least its rung, text wrapping rather than cut. Measured, not assumed, on every UI change. [WCAG 1.4.4, 1.4.10, 1.4.12] @@ -688,3 +689,4 @@ of a full-width panel and nothing on the right. | 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways | | 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times | | 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin | +| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses | diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index a15c2ca3..8df8bd86 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -414,7 +414,9 @@ export default function ChannelPage({ {/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} -
+ {/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and + the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */} +
-
+
toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} - onCreate={(name) => createAndAttachTag(channelId, name)} - // The header row has free width — show tags inline instead of collapsing to "+N". - bounded={false} - /> +
+ toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} + onCreate={(name) => createAndAttachTag(channelId, name)} + // The header row's leftover width: tags inline, "+N" only when they do not fit. + bounded={false} + /> +
)}
diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index dc33daad..764709d6 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -10,7 +10,7 @@ // then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as // bookkeeping. import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; -import { renderHook, act, cleanup } from "@testing-library/react"; +import { renderHook, render as rtlRender, act, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; @@ -28,6 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => { import { NavigationProvider } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./navigationContext"; import { useBackToClose } from "../lib/history"; +import { PageHeading } from "./ui/Headings"; import { qk } from "../lib/queryKeys"; @@ -88,6 +89,41 @@ describe("NavigationProvider · the tab title", () => { expect(document.title).not.toBe("Siftlode"); expect(document.title.endsWith(" · Siftlode")).toBe(true); }); + + // E31 S1: the Feed's tab read a bare "Siftlode" — the one page that named no place. + it("names the Feed like every other page", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed"); + render(); + await settle(); + expect(document.title).toBe("header.account.feed · Siftlode"); + }); + + // E31 S1: a channel opened from a link was "Channels · Siftlode", the same channel opened in the + // app was named; a Plex title, person or collection only "Plex". The object is the view's heading. + it("puts the open view's own heading before the place, and drops it when the view closes", async () => { + window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex"); + function View({ name }: { name: string | null }) { + return name === null ? null : {name}; + } + const view = rtlRender(, { wrapper }); + await settle(); + expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone + + view.rerender(); + await settle(); + expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode"); + + view.rerender(); + await settle(); + expect(document.title).toBe("plex.navLabel · Siftlode"); + }); + + it("takes a heading's tabTitle over its text", async () => { + window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex"); + rtlRender(Season 1, { wrapper }); + await settle(); + expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode"); + }); }); describe("NavigationProvider · setPage's same-page guard", () => { diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 9aea5a63..c17cfc51 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -4,6 +4,7 @@ import { useMe } from "../lib/useMe"; import { urlWithParams, type Page } from "../lib/urlState"; import { LOGS_KEYS } from "../lib/logsUrlState"; import { pageTitleKey } from "../lib/pageMeta"; +import { useDocumentObjectTitle } from "../lib/documentTitle"; import { setAccountRaw } from "../lib/storage"; import { setNavigator } from "../lib/nav"; import { @@ -164,21 +165,17 @@ export function NavigationProvider({ children }: { children: ReactNode }) { if (page !== "feed") setYtSearch(null); } - // Reflect the current module in the browser tab title. Feed = brand only; an open channel page - // shows the channel name. Signed out, the page on screen is the public landing whatever `page` + // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): + // " · · Siftlode". The object is the open view's own heading (a channel, a Plex + // title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same + // as one opened in the app. Signed out, the page on screen is the public landing whatever `page` // says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19). const signedIn = useMe() != null; + const objectTitle = useDocumentObjectTitle(); useEffect(() => { - const brand = "Siftlode"; - const label = !signedIn - ? null - : channelView - ? channelView.name || t("header.channelManager") - : page === "feed" - ? null - : t(pageTitleKey(page)); - document.title = label ? `${label} · ${brand}` : brand; - }, [signedIn, page, channelView, i18n.language, t]); + const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : []; + document.title = [...parts.filter(Boolean), "Siftlode"].join(" · "); + }, [signedIn, page, objectTitle, i18n.language, t]); // The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history // entry: every later page change writes its own entry through setPage. diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 90372f59..10649ea6 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -443,3 +443,28 @@ describe("PlayerModal — the side arrows", () => { expect(arrow.parentElement!.className).toMatch(/sm:px-14/); }); }); + +// E31 S1: at 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel +// row. The card's width is capped from the window height less what sits under the video, measured. +describe("PlayerModal — fits the window's height", () => { + it("caps the card's width by the chrome measured under the video", () => { + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return this.classList.contains("player-stage") ? 470 : 0; + }); + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return this.querySelector(":scope > .player-stage") ? 630 : 0; + }); + const A = vid("A", 0); + const B = vid("B", 0); + renderModal( + {}} onState={() => {}} />, + ); + const row = document.querySelector(".player-stage")!.parentElement!.parentElement!; + expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))"); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index b492edb2..c066371f 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useEffectEvent, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useNavigationActions } from "./navigationContext"; @@ -181,6 +189,25 @@ export default function PlayerModal({ // useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their // dep lists too. const focusModal = useCallback(() => cardRef.current?.focus(), []); + // The card fits the window, video AND what sits under it (the queue strip, title, channel row): the + // video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a + // full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's + // height, measured (it wraps with the title and the text size), and caps the card's width below. + const [below, setBelow] = useState(0); + useLayoutEffect(() => { + const card = cardRef.current; + const stage = stageRef.current; + if (!card || !stage) return; + const measure = () => { + // A maximised stage is fixed to the viewport, out of the card's flow: keep the last value. + if (getComputedStyle(stage).position === "fixed") return; + setBelow(card.scrollHeight - stage.offsetHeight); + }; + measure(); + const ro = new ResizeObserver(measure); + for (const child of card.children) ro.observe(child); + return () => ro.disconnect(); + }, []); // The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back // to whatever opened the player, and the page lock is the one counted lock. It opens focused on the // CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one @@ -579,7 +606,14 @@ export default function PlayerModal({ it and a portrait tablet lost it). stopPropagation so a click steps instead of closing. Hidden on narrow screens where there's no room beside the card. */}
{hasQueue && ( ) : ( -

{plName(detail)}

+ + {plName(detail)} + )}
{linked && detail.dirty && ( diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index a3147178..caa640e0 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { vocab } from "../lib/vocab"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react"; import { qk } from "../lib/queryKeys"; @@ -262,7 +263,7 @@ export default function PlexCollectionView({ const meta = [ d?.year_min != null && (d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`), - d?.content_rating, + d?.content_rating && vocab(t, "certificate", d.content_rating), d && t("plex.browse.items", { count: d.child_count ?? items.length }), formatRuntime(d?.total_runtime_s, lang), ].filter(Boolean) as string[]; diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 8554e15e..bbd5cc8f 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -15,8 +15,13 @@ vi.mock("react-i18next", async (importOriginal) => { ...actual, useTranslation: () => ({ i18n: { language: "en" }, + // A certificate code ("R") is no catalog word: it falls back to itself, as in the real catalog. t: (key: string, opts?: Record) => - opts?.name ? `${key}:${opts.name}` : key, + opts?.name + ? `${key}:${opts.name}` + : key.startsWith("vocab.certificate.") && typeof opts?.defaultValue === "string" + ? opts.defaultValue + : key, }), }; }); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index de959932..c4f589aa 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -187,7 +187,7 @@ function FactsPanel({ label: t("plex.info.facts.cert"), node: ( onFilter({ contentRatings: [cert] }) : undefined}> - {cert} + {vocab(t, "certificate", cert)} ), }); @@ -589,7 +589,7 @@ export default function PlexInfo({ : undefined } > - {detail.content_rating} + {vocab(t, "certificate", detail.content_rating)} )} {detail.duration_seconds ? ( diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index fb3e83ef..167fa7ab 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { qk } from "../lib/queryKeys"; import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles"; import { useTranslation } from "react-i18next"; +import { vocab } from "../lib/vocab"; import { plexLang } from "../i18n"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ListMusic, Plus, SlidersHorizontal } from "lucide-react"; @@ -247,7 +248,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl body: ( ({ + ...o, + label: vocab(t, "certificate", o.value), + }))} selected={filters.contentRatings} onChange={(next) => patch({ contentRatings: next })} /> diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 0481054f..189aa715 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -583,7 +583,7 @@ export function PlexShowView({ : undefined } > - {show.content_rating} + {vocab(t, "certificate", show.content_rating)} )} {show.season_count != null && ( @@ -838,7 +838,10 @@ export function PlexSeasonView({ - + {seasonTitle(se, t)}

diff --git a/frontend/src/components/ui/Headings.tsx b/frontend/src/components/ui/Headings.tsx index d671e7b3..1fa960bb 100644 --- a/frontend/src/components/ui/Headings.tsx +++ b/frontend/src/components/ui/Headings.tsx @@ -1,4 +1,5 @@ -import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { type ComponentPropsWithoutRef, type ReactNode, useRef } from "react"; +import { useNameTabAfter } from "../../lib/documentTitle"; import { useOwnPageHeading } from "./pageHeading"; // The page's heading outline (B4, UX-15): one `h1` per page, then a heading per section. A screen @@ -6,10 +7,16 @@ import { useOwnPageHeading } from "./pageHeading"; // structure from exactly the people who navigate by it. /** A view's own visible title (a channel, a Plex show, a playlist): the page's `h1`, in place of the - * shell's hidden page name. */ -export function PageHeading(props: ComponentPropsWithoutRef<"h1">) { + * shell's hidden page name, and the object the browser tab names (`lib/documentTitle`). `tabTitle` + * only when the heading alone does not say what it is (a season: "Season 1" → "Voyager · Season 1"). */ +export function PageHeading({ + tabTitle, + ...props +}: ComponentPropsWithoutRef<"h1"> & { tabTitle?: string }) { useOwnPageHeading(); - return

; + const ref = useRef(null); + useNameTabAfter(ref, tabTitle); + return

; } /** A section's caption: the small uppercase muted label, as a heading. `level` 3 inside a dialog or diff --git a/frontend/src/components/usePlexActiveFilters.dom.test.tsx b/frontend/src/components/usePlexActiveFilters.dom.test.tsx index 85436e37..d036e19f 100644 --- a/frontend/src/components/usePlexActiveFilters.dom.test.tsx +++ b/frontend/src/components/usePlexActiveFilters.dom.test.tsx @@ -23,6 +23,7 @@ const plexState = { filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"], + contentRatings: ["Not Rated"], // "A" was picked in the sidebar (its title came along); "B" arrived from a shared LINK, which // carries ids only — the case that used to render a raw rating_key. collections: ["A", "B"], @@ -64,6 +65,13 @@ describe("usePlexActiveFilters", () => { expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true); }); + // E31 S1: the chip read Plex's English word ("Not Rated") under a Hungarian UI. + it("names a certificate chip through the vocabulary, keeping Plex's value as the key", () => { + const { result } = renderHook(() => usePlexActiveFilters(), { wrapper }); + const chip = result.current.find((c) => c.key === "contentRating:Not Rated"); + expect(chip?.label).toBe("vocab.certificate.NotRated"); + }); + it("labels a link-supplied collection with its resolved title, not the bare id", () => { // Seed the resolution the hook would fetch, so this asserts the WIRING (hook -> builder -> label) // rather than the network. diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index a0a796dc..9dcc3b78 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { vocabI18nKey } from "../lib/vocab"; import { type ActiveFilter } from "../lib/useActiveFilters"; import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; @@ -53,7 +54,9 @@ export function buildPlexActiveFilters( for (const r of filters.contentRatings) { out.push({ key: `contentRating:${r}`, - label: r, + label: vocabI18nKey("certificate", r) + ? t(vocabI18nKey("certificate", r), { defaultValue: r }) + : r, remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }), }); } diff --git a/frontend/src/i18n/locales/en/vocab.json b/frontend/src/i18n/locales/en/vocab.json index 2f30ae4c..ea77125f 100644 --- a/frontend/src/i18n/locales/en/vocab.json +++ b/frontend/src/i18n/locales/en/vocab.json @@ -165,5 +165,11 @@ "mono": "Mono", "audioN": "Audio {{n}}", "subtitleN": "Subtitle {{n}}" + }, + "certificate": { + "NotRated": "Not Rated", + "Unrated": "Unrated", + "Approved": "Approved", + "Passed": "Passed" } } diff --git a/frontend/src/i18n/locales/hu/vocab.json b/frontend/src/i18n/locales/hu/vocab.json index 36efe96e..7d969f14 100644 --- a/frontend/src/i18n/locales/hu/vocab.json +++ b/frontend/src/i18n/locales/hu/vocab.json @@ -165,5 +165,11 @@ "mono": "monó", "audioN": "{{n}}. hangsáv", "subtitleN": "{{n}}. felirat" + }, + "certificate": { + "NotRated": "Nincs besorolva", + "Unrated": "Besorolatlan", + "Approved": "Jóváhagyva", + "Passed": "Engedélyezve" } } diff --git a/frontend/src/lib/documentTitle.ts b/frontend/src/lib/documentTitle.ts new file mode 100644 index 00000000..ff6fbc10 --- /dev/null +++ b/frontend/src/lib/documentTitle.ts @@ -0,0 +1,58 @@ +import { type RefObject, useLayoutEffect, useRef, useSyncExternalStore } from "react"; + +// The browser tab names the place AND the object in it (DESIGN § Composition and the reader, "Where am +// I"): "Nicolas Cage · Plex · Siftlode". The place is the module (NavigationProvider); the object is the +// view's own visible title, so it is read from the element that already shows it (`PageHeading`), not +// handed over a second time by every view — a view with a title on screen names the tab by having it. +// The latest mounted title wins (a view stacked over another); an empty one (still loading) is skipped. + +type Entry = { text: string }; +let entries: Entry[] = []; +const subs = new Set<() => void>(); + +function emit(): void { + for (const cb of subs) cb(); +} + +function snapshot(): string | null { + let found: string | null = null; + for (const e of entries) if (e.text) found = e.text; + return found; +} + +/** The object the screen shows, for the tab title; null when the view has no title of its own. */ +export function useDocumentObjectTitle(): string | null { + return useSyncExternalStore( + (cb) => { + subs.add(cb); + return () => subs.delete(cb); + }, + snapshot, + snapshot, + ); +} + +/** Names the tab after what `el` shows (its text), or after `override` when given (a season page's + * heading is "Season 1"; the tab wants the show too). Re-read after every render, so a title that + * arrives with its data, or changes in place, follows. */ +export function useNameTabAfter(el: RefObject, override?: string): void { + const entry = useRef(null); + useLayoutEffect(() => { + const e: Entry = { text: "" }; + entry.current = e; + entries = [...entries, e]; + return () => { + entries = entries.filter((x) => x !== e); + entry.current = null; + emit(); + }; + }, []); + // No dependency list: the text is the DOM's, read after each render of the heading. + useLayoutEffect(() => { + const e = entry.current; + const text = (override ?? el.current?.textContent ?? "").replace(/\s+/g, " ").trim(); + if (!e || e.text === text) return; + e.text = text; + emit(); + }); +} diff --git a/frontend/src/lib/vocab.dom.test.ts b/frontend/src/lib/vocab.dom.test.ts index 81047ef1..8e1b9e42 100644 --- a/frontend/src/lib/vocab.dom.test.ts +++ b/frontend/src/lib/vocab.dom.test.ts @@ -18,6 +18,10 @@ describe("a vocabulary word", () => { ["autoTag", "Hungarian", "Magyar"], ["profile", "Video only (MP4)", "Csak videó (MP4)"], ["topic", "Lifestyle_(sociology)", "Életmód"], + // E31 S1: Plex's certificate words came through in English on the info badge and the filter. + ["certificate", "Not Rated", "Nincs besorolva"], + ["certificate", "Approved", "Jóváhagyva"], + ["certificate", "Passed", "Engedélyezve"], ] as const)("%s %j reads %j in Hungarian", (group, word, shown) => { expect(vocab(hu, group, word)).toBe(shown); }); @@ -27,6 +31,11 @@ describe("a vocabulary word", () => { expect(vocab(en, "topic", "Pop_music", "Pop music")).toBe("Pop music"); }); + it("a certificate code reads the same in every language", () => { + expect(vocab(hu, "certificate", "PG-13")).toBe("PG-13"); + expect(vocab(hu, "certificate", "TV-MA")).toBe("TV-MA"); + }); + it("an unknown word is shown, not hidden or keyed", () => { expect(vocab(hu, "crewJob", "Gaffer")).toBe("Gaffer"); expect(vocab(hu, "topic", "Knitting_(craft)", "Knitting (craft)")).toBe("Knitting (craft)"); diff --git a/frontend/src/lib/vocab.ts b/frontend/src/lib/vocab.ts index d93add8b..0336e392 100644 --- a/frontend/src/lib/vocab.ts +++ b/frontend/src/lib/vocab.ts @@ -2,23 +2,31 @@ import type { TFunction } from "i18next"; import type { PlexTrackNaming } from "./api"; // E30: the closed vocabularies the server stores in ENGLISH — a TMDB crew job or department, an -// auto-tag, a built-in download profile, a YouTube topic — named in the reader's language. The data +// auto-tag, a built-in download profile, a YouTube topic, a Plex certificate's word ("Not Rated"; a +// code such as "PG-13" reads the same everywhere and falls through as stored) — named in the reader's language. The data // stays language-neutral (it is filtered and matched on); only the NAME is translated, here, from the // `vocab` locale area. A word the area does not hold shows as stored, so a new server value is never // hidden — and the backend's own lists are held key-for-key to the area by backendIds.contract. -type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic"; +type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic" | "certificate"; /** A vocabulary word's key: its letters and digits ("Health & Fitness" → "HealthFitness"). */ export function vocabKey(word: string): string { return word.replace(/[^A-Za-z0-9]/g, ""); } +/** The i18n key of one word of `group` ("" when the word has no letters or digits) — for a caller + * whose `t` is not i18next's own (a pure builder handed a narrow `t`). */ +export function vocabI18nKey(group: Group, word: string): string { + const key = vocabKey(word); + return key ? `vocab.${group}.${key}` : ""; +} + /** One stored English word of `group`, in the reader's language (`fallback`, by default the word * itself, when the area does not hold it). */ export function vocab(t: TFunction, group: Group, word: string, fallback = word): string { - const key = vocabKey(word); - return key ? t(`vocab.${group}.${key}`, { defaultValue: fallback }) : fallback; + const key = vocabI18nKey(group, word); + return key ? t(key, { defaultValue: fallback }) : fallback; } /** A download profile's name: a built-in one is seeded under an English name and named here; a user's From ab146211fa53aa71b4ad3cfb06c848504540a6fa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 22:51:37 +0200 Subject: [PATCH 03/31] fix(scheduler,i18n): a failure a job names reads in the reader's language The audit log showed a failed RSS poll as the server's English sentence under a Hungarian UI. A job can now raise progress.JobFailed(reason, message, **params): the scheduler stores the reason and params as the run's data (dashboard, manual-run notice, audit row) beside the English line, and the client renders scheduler.failure.. The RSS outage names its first channel and the reason code (HTTP 404 vs unreachable). An upstream's own error text stays as raised (diagnostic). --- backend/app/progress.py | 13 ++++++++ backend/app/scheduler.py | 11 +++++-- backend/app/sync/runner.py | 27 +++++++++++++---- backend/tests/test_scheduler.py | 28 +++++++++++++++++ backend/tests/test_sync_guards.py | 10 ++++++- frontend/src/components/Scheduler.tsx | 7 +++-- frontend/src/i18n/locales/en/scheduler.json | 9 ++++++ frontend/src/i18n/locales/hu/scheduler.json | 9 ++++++ frontend/src/lib/jobResult.dom.test.ts | 33 ++++++++++++++++++++- frontend/src/lib/jobResult.ts | 30 ++++++++++++++++--- 10 files changed, 160 insertions(+), 17 deletions(-) diff --git a/backend/app/progress.py b/backend/app/progress.py index 7be04061..b6e3f900 100644 --- a/backend/app/progress.py +++ b/backend/app/progress.py @@ -24,6 +24,19 @@ def report(current: int, total: int | None = None, phase: str | None = None) -> sink(current, total, phase) +class JobFailed(RuntimeError): + """A job's own failure, said so the reader's language can name it (E31): the client reads + `scheduler.failure.` and interpolates `params` (keep them to values the reader may see — + numbers, ids, short codes). The message is the English line the log and `last_error` keep. An + upstream's own error (a timeout, a refused connection) is not one of these: it stays as raised, + being diagnostic.""" + + def __init__(self, reason: str, message: str, **params: object): + super().__init__(message) + self.reason = reason + self.params = params + + class bind: """Context manager that binds a progress sink for the duration of a job run.""" diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index feaf43fd..f54dad64 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -231,17 +231,22 @@ def _job(name: str, fn) -> None: db.rollback() log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name)) err = str(exc) or exc.__class__.__name__ + # A failure the job named (progress.JobFailed) rides along as data, as a result does, so the + # client says it in the reader's language; `err` stays the English line. + failure = ( + {"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None + ) _record( name, status="error", last_finished=_now_iso(), last_error=err, - result=None, + result=failure, progress=None, ) if actor_id is not None: - _notify_done(db, actor_id, name, "error", err) - _audit_run(db, actor_id, name, "error", err, None) + _notify_done(db, actor_id, name, "error", err, failure) + _audit_run(db, actor_id, name, "error", err, failure) finally: # ALWAYS release the claim, even if the body raised before its own status update — so a # failure can never wedge the job as permanently "running". diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index 28fa2cb4..b495fbdf 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -83,6 +83,8 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str # outage from one permanently dead feed id — which is the whole difference on an instance with # a single subscription, where BOTH render as 100% failed. first_error: str | None = None + # The same first failure as data, for the reader's language: whose feed, and why (`reasons`' key). + first_failure: tuple[str, str] | None = None # What failed, as counts per reason (`http_404`, `transport`, `apply`, …) plus a few examples — # the ONE warning a poll writes. One line per failed channel was what turned YouTube's RSS outage # of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now. @@ -92,18 +94,19 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str # hand the same rows to the same broken write) — the feed fallback's candidates. unread: list[str] = [] - def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None: + def fail(reason: str, channel_id: str, detail: str, *, unexpected: str | None = None) -> None: """Count a channel that did not get polled. `unexpected` is the message for a failure that is a bug on our side: its FIRST occurrence per reason gets a traceback at ERROR, the rest DEBUG — a read-only database fails every channel the same way, and 979 identical tracebacks tell nobody anything the first one didn't. Call it from inside the `except` block.""" - nonlocal failed, first_error + nonlocal failed, first_error, first_failure if unexpected is not None: level = logging.ERROR if reasons[reason] == 0 else logging.DEBUG log.log(level, unexpected, exc_info=True) failed += 1 reasons[reason] += 1 first_error = first_error or detail + first_failure = first_failure or (channel_id, reason) if len(examples) < RSS_FAILURE_EXAMPLES: examples.append(detail) @@ -125,13 +128,16 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str # bug on our side and earns a traceback (the first of its kind per poll). except RssError as exc: log.debug("RSS poll skipped for channel %s: %s", channel.id, exc) - fail(exc.reason, f"{channel.id}: {exc}") + fail(exc.reason, channel.id, f"{channel.id}: {exc}") unread.append(channel.id) # silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the # traceback, once per reason per poll, which is the flood control this loop needs. except Exception as exc: # noqa: BLE001 — one channel must not stop the poll fail( - "error", f"{channel.id}: {exc}", unexpected=f"RSS fetch failed for {channel.id}" + "error", + channel.id, + f"{channel.id}: {exc}", + unexpected=f"RSS fetch failed for {channel.id}", ) else: try: @@ -144,6 +150,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str db.rollback() fail( "apply", + channel.id, f"{channel.id} (apply): {exc}", unexpected=f"RSS apply failed for {channel.id}", ) @@ -180,9 +187,17 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str # "fix this subscription"; `Connection refused` says "the egress is down". # The fallback's count rides along: a red card during an RSS outage should still say # whether the uploads are arriving another way. - raise RuntimeError( + # As data too (E31): the audit log and the inbox say it in the reader's language, the first + # failure as its channel and its reason code (`http_404` vs `transport` is that difference). + first_channel, first_reason = first_failure or ("", "error") + raise progress.JobFailed( + "rss_all_failed", f"RSS poll failed for all {total} channels — first: {first_error}" - f" (read through the API instead: {fallback['read']})" + f" (read through the API instead: {fallback['read']})", + total=total, + channel=first_channel, + cause=first_reason, + read=fallback["read"], ) return {"new": new + fallback["new"], "failed": failed, "fallback": fallback["read"]} diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index f77cd6c8..abeac71f 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -86,3 +86,31 @@ def test_a_job_runs_audit_row_carries_its_result_as_data(db): {"status": "skipped", "summary": "sync paused", "result": {"skipped": "sync paused"}}, {"status": "error", "summary": "boom"}, ] + + +def test_a_failure_the_job_names_rides_along_as_data(db, make_user): + """E31: a failed RSS poll reached the Hungarian audit log as the server's English sentence. A + failure the job names (`progress.JobFailed`) is stored as data beside that sentence — on the + dashboard, the manual-run notice and the audit row — so the client says it in the reader's + language. Any other failure keeps only its own text (the test above).""" + from app import progress + from app.models import AuditLog + + def outage(_db): + raise progress.JobFailed("rss_all_failed", "RSS poll failed", total=2, cause="transport") + + admin = make_user("failure@example.com", role="admin") + token = scheduler._manual_actor.set(admin.id) + try: + scheduler._job("rss_poll", outage) + finally: + scheduler._manual_actor.reset(token) + + named = {"failure": "rss_all_failed", "total": 2, "cause": "transport"} + rss = _snapshot_job("rss_poll") + assert rss["last_error"] == "RSS poll failed" + assert rss["result"] == named + notice = db.execute(select(Notification).where(Notification.user_id == admin.id)).scalar_one() + assert notice.data["result"] == named + row = db.execute(select(AuditLog).order_by(AuditLog.id.desc())).scalars().first() + assert row is not None and row.after["result"] == named diff --git a/backend/tests/test_sync_guards.py b/backend/tests/test_sync_guards.py index 824f62d9..8408cbc7 100644 --- a/backend/tests/test_sync_guards.py +++ b/backend/tests/test_sync_guards.py @@ -6,6 +6,7 @@ import logging import httpx import pytest +from app import progress from app.sync import runner from app.sync.playlists import FAILED, _Breaker from app.sync.subscriptions import should_prune @@ -224,8 +225,15 @@ class TestRssPollResult: def dead(cid): raise rss.RssError(f"RSS fetch for {cid} returned HTTP 404", "http_404") - with pytest.raises(RuntimeError, match="HTTP 404"): + with pytest.raises(RuntimeError, match="HTTP 404") as err: self._run(monkeypatch, fetch=dead, apply_=lambda db, ch, e: 1, channels=("UC_dead",)) + # E31: and as data, for the reader's language — whose feed, and the reason code that tells a + # dead feed (`http_404`) from a dead egress (`transport`). + assert isinstance(err.value, progress.JobFailed) + assert (err.value.reason, err.value.params) == ( + "rss_all_failed", + {"total": 1, "channel": "UC_dead", "cause": "http_404", "read": 0}, + ) def test_a_read_only_database_is_a_total_outage_too(self, monkeypatch): # Every feed reads fine, nothing can be stored: just as un-polled, just as red. diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 2adeeecc..7e95738f 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -35,7 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu"; import { ContextMenu } from "./ui/ContextMenu"; import { MoreButton } from "./ui/MoreButton"; import { type ActionMenuItem } from "./ui/ActionMenu"; -import { jobResultText } from "../lib/jobResult"; +import { jobFailureText, jobResultText } from "../lib/jobResult"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. @@ -257,7 +257,10 @@ function JobRow({ ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) }) : t("scheduler.neverRun")} {job.last_error ? ( - · {job.last_error} + + {" · "} + {jobFailureText(t, job.result, lang) ?? job.last_error} + ) : job.result ? ( · {jobResultText(t, job.result, lang)} ) : job.last_result ? ( diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 1b01dac0..c6b82cc5 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -186,5 +186,14 @@ "lowquota": "low quota", "noreadscope": "no YouTube read access", "busy": "another sync is running" + }, + "failure": { + "rss_all_failed": "The RSS feed answered for none of the {{total}} channels — the first: {{channel}}, {{cause}}. Read through the API instead: {{read}}" + }, + "rssCause": { + "http": "HTTP {{status}}", + "transport": "unreachable", + "apply": "could not be saved", + "error": "unexpected error" } } diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 06755b52..f1011665 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -186,5 +186,14 @@ "lowquota": "kevés a kvóta", "noreadscope": "nincs YouTube-olvasási jogosultság", "busy": "egy másik szinkron fut" + }, + "failure": { + "rss_all_failed": "Az RSS-hírcsatorna egyik csatornánál sem válaszolt ({{total}}) — az első: {{channel}}, {{cause}}. Az API-n át beolvasva helyette: {{read}}" + }, + "rssCause": { + "http": "HTTP {{status}}", + "transport": "nem érhető el", + "apply": "nem sikerült menteni", + "error": "váratlan hiba" } } diff --git a/frontend/src/lib/jobResult.dom.test.ts b/frontend/src/lib/jobResult.dom.test.ts index 4a328d4c..01044f74 100644 --- a/frontend/src/lib/jobResult.dom.test.ts +++ b/frontend/src/lib/jobResult.dom.test.ts @@ -4,7 +4,7 @@ // one-liner was "recent={'channels_processed': 0, …}, deep={…}" — English keys and a Python repr. import { afterEach, describe, expect, it } from "vitest"; import i18n, { setLanguage } from "../i18n"; -import { jobResultText, parseSummary } from "./jobResult"; +import { jobFailureText, jobResultText, jobRunText, parseSummary } from "./jobResult"; afterEach(() => setLanguage("en")); @@ -65,3 +65,34 @@ describe("an older notice's one-liner", () => { expect(parseSummary(null)).toBeNull(); }); }); + +// E31: the audit log showed a failed RSS poll as the server's English sentence ("RSS poll failed for +// all 399 channels — first: …") under a Hungarian UI. A failure the job names rides along as data. +describe("a failed run", () => { + const run = { + job_id: "rss_poll", + status: "error", + summary: + "RSS poll failed for all 399 channels — first: UCx: RSS fetch for UCx returned HTTP 404", + result: { failure: "rss_all_failed", total: 1399, channel: "UCx", cause: "http_404", read: 0 }, + }; + + it("says the failure the job named in the reader's language", () => { + const t = i18n.getFixedT("hu"); + expect(jobRunText(t, run, "hu").body).toBe( + "Az RSS-hírcsatorna egyik csatornánál sem válaszolt (1399) — az első: UCx, HTTP 404. " + + "Az API-n át beolvasva helyette: 0", + ); + expect(jobFailureText(t, { ...run.result, cause: "transport" }, "hu")).toContain( + "UCx, nem érhető el.", + ); + }); + + it("keeps any other failure's own text", () => { + const t = i18n.getFixedT("hu"); + expect(jobRunText(t, { ...run, summary: "Plex returned 500", result: null }, "hu").body).toBe( + "Plex returned 500", + ); + expect(jobFailureText(t, { failure: "moon_is_full" }, "hu")).toBeNull(); + }); +}); diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts index 53365236..823ccfac 100644 --- a/frontend/src/lib/jobResult.ts +++ b/frontend/src/lib/jobResult.ts @@ -38,10 +38,30 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st .join(" · "); } +/** A failure the job named (the server's `progress.JobFailed`: `{failure: , …params}`) in the + * reader's language (E31); null for any other failure, which keeps the upstream's own text. */ +export function jobFailureText(t: TFunction, result: unknown, lang: string): string | null { + if (!result || typeof result !== "object") return null; + const { failure, ...params } = result as Record; + if (typeof failure !== "string") return null; + const values: Record = {}; + for (const [k, v] of Object.entries(params)) + values[k] = typeof v === "number" ? formatNumber(v, lang) : String(v); + if (typeof params.cause === "string") values.cause = rssCause(t, params.cause); + return t(`scheduler.failure.${failure}`, { ...values, defaultValue: "" }) || null; +} + +/** Why a feed did not answer, from the poll's reason code (`http_404`, `transport`, …). */ +function rssCause(t: TFunction, cause: string): string { + const http = /^http_(\d+)$/.exec(cause); + if (http) return t("scheduler.rssCause.http", { status: http[1] }); + return t(`scheduler.rssCause.${cause}`, { defaultValue: cause }); +} + /** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job * and how the run ended, and its result in the reader's language — from the data when it rode along, - * else read back from the English one-liner. A skipped run names only why; a failed run keeps the - * upstream's own error text (it is diagnostic). */ + * else read back from the English one-liner. A skipped run names only why; a failed run says the + * failure the job named (`jobFailureText`, E31), else keeps the upstream's own text (diagnostic). */ export function jobRunText( t: TFunction, run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown }, @@ -65,10 +85,12 @@ export function jobRunText( } // A skipped run's title already says "skipped": its body is only the reason, not "skipped: ". const reason = run.status === "skipped" ? onlySkipReason(result) : null; + const failure = run.status === "error" ? jobFailureText(t, run.result, lang) : null; return { title: t(titleKey, { job }), - body: - reason !== null + body: failure + ? failure + : reason !== null ? t(`scheduler.skip.${vocabKey(reason)}`, { defaultValue: reason }) : result ? jobResultText(t, result, lang) From 87d71e3762de4a8b5d5b6e7ab1095c7f0f0cff27 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 22:51:38 +0200 Subject: [PATCH 04/31] =?UTF-8?q?feat(ui):=20E31=20S4a=20=E2=80=94=20rail?= =?UTF-8?q?=20rows=20are=20links,=20the=20channel=20page=20opens=20on=20un?= =?UTF-8?q?watched,=20users=20and=20audit=20tidied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nav rail page rows are real links (/?page=): a middle-click or Ctrl-click opens the page in a new tab; a plain click stays in-app. - A fresh channel page shows the unwatched videos; a picked watch state is a remembered preference (channelShow). - Users: the role, suspend and delete move into the row's one menu (the "…", right-click, Shift+F10); locked ones keep their reason. - Audit log: back-to-back successful runs of one job fold into one row that opens to the earlier ones; a failed run reads in the danger colour. - DESIGN.md decision log: the E31 picks. --- docs/DESIGN.md | 10 ++ .../src/components/AdminUsers.dom.test.tsx | 54 ++++++- frontend/src/components/AdminUsers.tsx | 140 ++++++++---------- frontend/src/components/AuditEarlierRuns.tsx | 48 ++++++ frontend/src/components/AuditLog.tsx | 4 +- frontend/src/components/ChannelPage.tsx | 18 ++- .../ChannelPage.watchState.dom.test.tsx | 109 ++++++++++++++ frontend/src/components/Feed.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 3 + .../src/components/auditColumns.dom.test.tsx | 48 +++++- frontend/src/components/auditColumns.tsx | 17 ++- frontend/src/components/feedFiltersInit.ts | 3 + .../src/components/ui/NavRail.dom.test.tsx | 51 ++++++- frontend/src/components/ui/NavRail.tsx | 17 ++- .../ui/pressableSelectable.contract.test.ts | 2 +- frontend/src/i18n/locales/en/audit.json | 4 +- frontend/src/i18n/locales/en/users.json | 3 +- frontend/src/i18n/locales/hu/audit.json | 4 +- frontend/src/i18n/locales/hu/users.json | 3 +- frontend/src/lib/auditGroups.test.ts | 60 ++++++++ frontend/src/lib/auditGroups.ts | 32 ++++ frontend/src/lib/nav.ts | 7 + frontend/src/lib/storage.ts | 1 + 23 files changed, 542 insertions(+), 98 deletions(-) create mode 100644 frontend/src/components/AuditEarlierRuns.tsx create mode 100644 frontend/src/components/ChannelPage.watchState.dom.test.tsx create mode 100644 frontend/src/lib/auditGroups.test.ts create mode 100644 frontend/src/lib/auditGroups.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4a85a08a..d128c257 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -690,3 +690,13 @@ of a full-width panel and nothing on the right. | 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times | | 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin | | 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses | +| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only | +| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages | +| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance | +| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight | +| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick | +| 2026-10-01 | E31 Audit log runs | Back-to-back successful runs of one job fold into one row that opens; a failure is never folded and reads in the danger colour | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen | +| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu | +| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible | +| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page | +| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site | diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx index 81c87530..c6eb94b3 100644 --- a/frontend/src/components/AdminUsers.dom.test.tsx +++ b/frontend/src/components/AdminUsers.dom.test.tsx @@ -116,13 +116,63 @@ describe("a user row", () => { }); }); +/** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */ +const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true }); + +/** The row's actions live in its "…" menu (E31): open it, then pick the row. */ +async function chooseInRowMenu(label: string) { + fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" })); + const item = await waitFor(() => { + const found = menuRows().find((m) => m.textContent?.startsWith(label)); + if (!found) throw new Error(`no menu row "${label}"`); + return found; + }); + fireEvent.click(item); +} + +// E31 (DESIGN § Action placement): the role, the suspension and the delete moved off the row into +// ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with +// its reason. +describe("a user row's menu", () => { + it("holds the role, the suspension and the delete; the row shows no action button", async () => { + mount(); + const open = await screen.findByTitle("users.account.open"); + const card = open.parentElement!; + const buttons = [...card.querySelectorAll("button")].filter((b) => b !== open); + expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual(["users.rowMenu"]); + fireEvent.contextMenu(card, { clientX: 5, clientY: 5 }); + await waitFor(() => expect(menuRows().length).toBe(3)); + expect(menuRows().map((i) => i.textContent)).toEqual([ + "users.roles.makeAdmin", + "users.suspend.action", + "users.delete.action", + ]); + }); + + it("lists your own row's actions as locked, with the reason", async () => { + vi.spyOn(api, "adminUsers").mockResolvedValue([{ ...row, id: 1, email: "admin@x" }] as never); + render( + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" })); + await waitFor(() => expect(menuRows().length).toBe(3)); + const [role, suspend, del] = menuRows(); + expect(role!.hasAttribute("disabled")).toBe(true); + expect(role!.textContent).toBe("users.roles.makeAdminusers.roles.selfLocked"); + expect(suspend!.textContent).toBe("users.suspend.actionusers.suspend.selfLocked"); + expect(del!.textContent).toBe("users.delete.actionusers.delete.locked"); + }); +}); + describe("a user action that fails", () => { it("says a user who is gone, and refetches the list", async () => { const users = mount(); vi.spyOn(globalThis, "fetch").mockResolvedValue( answer(404, { code: "not_found", reason: "user" }), ); - fireEvent.click(await screen.findByText("users.roles.makeAdmin")); + await chooseInRowMenu("users.roles.makeAdmin"); await waitFor(() => expect(notify).toHaveBeenCalledWith({ level: "error", @@ -136,7 +186,7 @@ describe("a user action that fails", () => { it("is not toasted on top of the error dialog the request already opened", async () => { mount(); vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin")); - fireEvent.click(await screen.findByText("users.roles.makeAdmin")); + await chooseInRowMenu("users.roles.makeAdmin"); const { reportError } = await import("../lib/errorDialog"); await waitFor(() => expect(reportError).toHaveBeenCalled()); expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 13d9e541..1703aba9 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -46,6 +46,10 @@ import { PageToolbar } from "./ui/PageShell"; import { useMe } from "../lib/useMe"; import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors"; import { Pressable } from "./ui/Pressable"; +import { useContextMenu } from "./ui/useContextMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import { MoreButton } from "./ui/MoreButton"; +import type { ActionMenuItem } from "./ui/ActionMenu"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; @@ -206,10 +210,58 @@ function UsersRoles({ me }: { me: Me }) { if (ok) del.mutate({ id: u.id, email: u.email }); }; + // The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and + // the delete — rare and two of them destructive — open from the row's "…", a right-click on the row, + // a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state + // badges, the last sign-in. A locked action (the demo, yourself) stays listed with its reason. + const rowMenu = useContextMenu({ keyOf: (u) => u.id }); + const menuItems = (u: AdminUserRow): ActionMenuItem[] => { + const self = u.id === me.id; + const pending = pendingIds.has(u.id); + const locked = (demoNote: string, selfNote: string) => + u.is_demo ? demoNote : self ? selfNote : undefined; + const roleNote = locked(t("users.roles.demoLocked"), t("users.roles.selfLocked")); + const suspendNote = locked(t("users.suspend.demoLocked"), t("users.suspend.selfLocked")); + const deleteNote = u.is_demo || self ? t("users.delete.locked") : undefined; + return [ + { + id: "role", + label: u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin"), + icon: , + disabled: pending || !!roleNote, + note: roleNote, + onSelect: () => onToggle(u), + }, + { + id: "suspend", + label: u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action"), + icon: u.is_suspended ? : , + disabled: pending || !!suspendNote, + note: suspendNote, + onSelect: () => onSuspend(u), + }, + { + id: "delete", + label: t("users.delete.action"), + icon: , + danger: true, + separatorBefore: true, + disabled: pending || !!deleteNote, + note: deleteNote, + onSelect: () => onDelete(u), + }, + ]; + }; + const rows = q.data ?? []; return (
+ {panelFor && setPanelFor(null)} />}

{t("users.roles.intro")}

{q.isError && !q.data ? ( @@ -232,6 +284,7 @@ function UsersRoles({ me }: { me: Me }) { // stretched layer. The hover used to be the name button's own and ended at the first badge.
{t("users.roles.unverified")} )} - - - - - onSuspend(u)} - aria-disabled={u.is_demo || self} - disabled={pendingIds.has(u.id)} - > - {u.is_suspended ? ( - - ) : ( - - )} - - - - onDelete(u)} - aria-disabled={u.is_demo || self} - disabled={pendingIds.has(u.id)} - > - - - - {/* How long ago this account last signed in. LAST in the row on purpose: the badges and - the role button before it vary in width row to row, so only the row's own right - edge lines the values up into one column. The exact moment is the hint here and a + {/* How long ago this account last signed in. At the row's right end on purpose (only the + fixed-width "…" after it): the badges before it vary in width row to row, so only + the right edge lines the values up into one column. The exact moment is the hint here and a line in the account panel; below `sm` the row has no room, and the panel still says it. */} +
); })} diff --git a/frontend/src/components/AuditEarlierRuns.tsx b/frontend/src/components/AuditEarlierRuns.tsx new file mode 100644 index 00000000..62036b7e --- /dev/null +++ b/frontend/src/components/AuditEarlierRuns.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import { ChevronRight } from "lucide-react"; +import type { AuditRow } from "../lib/api"; +import { Button } from "./ui/Button"; +import { RelativeTime } from "./ui/RelativeTime"; + +/** A folded run of one job's successful runs (lib/auditGroups, E31): the row shows the newest, this + * opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */ +export default function AuditEarlierRuns({ + runs, + detailOf, +}: { + runs: AuditRow[]; + detailOf: (run: AuditRow) => string | null; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const earlier = runs.slice(1); + return ( +
+ + {open && ( +
    + {earlier.map((run) => ( +
  • + + {detailOf(run)} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index b034905b..b2a5c97c 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -14,6 +14,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState"; import { auditColumns } from "./auditColumns"; import { notifyActionError } from "../lib/actionErrors"; import { compareText } from "../lib/collate"; +import { foldJobRuns } from "../lib/auditGroups"; // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run // summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via @@ -25,7 +26,8 @@ export default function AuditLog() { const confirm = useConfirm(); const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() }); const data = q.data; - const rows = useMemo(() => data?.items ?? [], [data]); + // Back-to-back successful runs of one job fold into one row (lib/auditGroups, E31). + const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]); const actionOptions = useMemo(() => { // By what the reader sees — the named action, in their alphabet — not by the code. diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 8df8bd86..8fea4170 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -29,6 +29,9 @@ import ChannelAboutContent from "./ChannelAbout"; import { PageToolbar } from "./ui/PageShell"; import { PageToolbarSlot } from "./ui/pageToolbarSlot"; import { useFeedFilters } from "./feedFiltersContext"; +import { SHOW_IDS } from "./feedFiltersInit"; +import { usePref } from "../lib/usePref"; +import { LS } from "../lib/storage"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -260,7 +263,13 @@ export default function ChannelPage({ // Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so // the explored, per-user-private videos show — the backend gates them to this explorer). - const [filters, setFilters] = useState(() => ({ + // The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows + // what is still unwatched, and a change sticks — across channels, reloads and devices. + const [show, setShow] = usePref("channelShow", "unwatched", { + localKey: LS.channelShow, + parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"), + }); + const [ownFilters, setOwnFilters] = useState(() => ({ tags: [], tagModes: DEFAULT_TAG_MODES, q: "", @@ -271,9 +280,14 @@ export default function ChannelPage({ includeNormal: globalFilters.includeNormal, includeShorts: globalFilters.includeShorts, includeLive: globalFilters.includeLive, - show: "all", + show: "unwatched", channelIds: [channelId], })); + const filters: FeedFilters = { ...ownFilters, show }; + const setFilters = (next: FeedFilters) => { + if (next.show !== show) setShow(next.show); + setOwnFilters(next); + }; const name = ch?.title ?? initialName ?? channelId; const ytUrl = channelYouTubeUrl(channelId, ch?.handle); diff --git a/frontend/src/components/ChannelPage.watchState.dom.test.tsx b/frontend/src/components/ChannelPage.watchState.dom.test.tsx new file mode 100644 index 00000000..1533fa0b --- /dev/null +++ b/frontend/src/components/ChannelPage.watchState.dom.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +// +// E31 (the owner): a freshly opened channel page shows what is still unwatched, and a watch state the +// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it. +import type { ReactNode } from "react"; +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { FeedFilters } from "../lib/api"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./navigationContext", () => ({ + useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), +})); +const feed = vi.hoisted(() => ({ + last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void }, +})); +vi.mock("./Feed", () => ({ + default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => { + feed.last = p; + return null; + }, +})); +vi.mock("./ChannelAbout", () => ({ default: () => null })); +vi.mock("./ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import ChannelPage from "./ChannelPage"; +import { api, type ChannelDetail, type Me } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + feed.last = null; +}); + +const CH = "UCqoAEDirJPjEUFcF2FklnBA"; + +function mount(preferences: Record = {}) { + vi.spyOn(api, "channelDetail").mockResolvedValue({ + id: CH, + title: "StarTalk", + subscribed: true, + stored_videos: 10, + } as unknown as ChannelDetail); + vi.spyOn(api, "tags").mockResolvedValue([] as never); + const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: 1, preferences } as unknown as Me); + render( + + {}} + onShowInFeed={() => {}} + /> + , + ); + return save; +} + +describe("ChannelPage — the watch state", () => { + it("shows only the unwatched videos on a freshly opened page", () => { + mount(); + expect(feed.last?.filters.show).toBe("unwatched"); + }); + + it("stores a picked watch state", () => { + const save = mount(); + act(() => feed.last!.setFilters({ ...feed.last!.filters, show: "all" })); + expect(feed.last?.filters.show).toBe("all"); + expect(save).toHaveBeenCalledWith({ channelShow: "all" }); + }); + + it("keeps the stored watch state after a reload", () => { + mount({ channelShow: "watched" }); + expect(feed.last?.filters.show).toBe("watched"); + }); + + it("falls back to unwatched for a stored value it does not know", () => { + mount({ channelShow: "bogus" }); + expect(feed.last?.filters.show).toBe("unwatched"); + }); +}); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 37a82723..a6fabbf5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -37,6 +37,7 @@ import { } from "../lib/feedSort"; import { type SortOption, type SortState } from "../lib/columnSort"; import { usePref } from "../lib/usePref"; +import { SHOW_IDS } from "./feedFiltersInit"; import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; @@ -64,7 +65,6 @@ const PAGE = 60; // The "Show" view filter, content-type filter and ordering live in the feed toolbar // (above the cards), not the filter sidebar. -const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; const CONTENT = [ { key: "includeNormal", label: "sidebar.content.normal" }, { key: "includeShorts", label: "sidebar.content.shorts" }, diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index e0967a64..7f19c5ee 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -35,6 +35,7 @@ import { RAIL_ROW, } from "./ui/NavRail"; import { usePrefs } from "./prefsContext"; +import { pageHref } from "../lib/nav"; // Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar // dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account @@ -185,6 +186,7 @@ export default function NavSidebar({ showLabel={railLabels} active={page === p} badge={badge} + href={pageHref(p)} onClick={() => setPage(p)} /> ); @@ -214,6 +216,7 @@ export default function NavSidebar({ shortLabel={t(moduleRailKey.settings)} showLabel={railLabels} active={page === "settings"} + href={pageHref("settings")} onClick={() => setPage("settings")} /> diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/components/auditColumns.dom.test.tsx index ebea3d16..f418518f 100644 --- a/frontend/src/components/auditColumns.dom.test.tsx +++ b/frontend/src/components/auditColumns.dom.test.tsx @@ -4,7 +4,7 @@ // "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's // data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it. import { afterEach, describe, expect, it } from "vitest"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import i18n, { setLanguage } from "../i18n"; import type { AuditRow } from "../lib/api"; import { auditColumns } from "./auditColumns"; @@ -152,3 +152,49 @@ describe("an audit row's details", () => { ); }); }); + +// E31: a folded run (lib/auditGroups) shows its newest run and opens to the earlier ones; a failed run +// is the line to see in a page of routine ones. +describe("a folded run of one job", () => { + const ok = (id: number, enriched: number) => + row({ + id, + target_type: "job", + target_id: "enrich", + after: { status: "ok", result: { enriched } }, + }); + + it("names how many earlier runs it holds, and opens to each", () => { + setLanguage("hu"); + const t = i18n.getFixedT("hu"); + const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!; + const runs = [ok(3, 50), ok(2, 40), ok(1, 30)]; + const { getByRole, container } = render(<>{col.render!({ ...runs[0]!, runs })}); + const toggle = getByRole("button", { name: "2 korábbi futás" }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector("li")).toBeNull(); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect([...container.querySelectorAll("li")].map((li) => li.textContent)).toEqual([ + "—dúsítva: 40", + "—dúsítva: 30", + ]); + }); + + it("has no toggle for a lone run", () => { + const t = i18n.getFixedT("hu"); + const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!; + const { container } = render(<>{col.render!(ok(1, 5))}); + expect(container.querySelector("button")).toBeNull(); + }); + + it("paints a failed run's line in the danger colour", () => { + const t = i18n.getFixedT("hu"); + const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!; + const failed = row({ target_type: "job", target_id: "enrich", after: { status: "error" } }); + const { container } = render(<>{col.render!(failed)}); + expect(container.querySelector(".wrap-break-word")!.className).toContain("text-danger"); + const { container: fine } = render(<>{col.render!(ok(2, 5))}); + expect(fine.querySelector(".wrap-break-word")!.className).not.toContain("text-danger"); + }); +}); diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx index d9181cb6..61b5ef4b 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -1,5 +1,8 @@ import type { TFunction } from "i18next"; +import clsx from "clsx"; import type { AuditRow } from "../lib/api"; +import type { AuditListRow } from "../lib/auditGroups"; +import AuditEarlierRuns from "./AuditEarlierRuns"; import { formatNumber } from "../lib/format"; import type { JobResult } from "../lib/api"; import { jobResultText, jobRunText } from "../lib/jobResult"; @@ -105,7 +108,7 @@ export function auditColumns( t: TFunction, lang: string, actionOptions: { value: string; label: string }[], -): Column[] { +): Column[] { return [ { key: "when", @@ -147,13 +150,23 @@ export function auditColumns( const local = localDetails(t, r, lang); const summary = local ? local.head : r.summary; const change = local ? local.sub : changeText(r); + // A failed run is the row to see in a page of routine ones (E31): its line in the danger colour. + const failed = r.action === "job_run" && r.after?.status === "error"; return ( // wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell // sizes to its longest row, so one long summary/change stretched the whole table past the // card (the row rules bled into the page). Wrapping keeps every row inside the container.
- {summary &&
{summary}
} + {summary && ( +
{summary}
+ )} {change &&
{change}
} + {r.runs && r.runs.length > 1 && ( + localDetails(t, run, lang)?.sub ?? run.summary} + /> + )}
); }, diff --git a/frontend/src/components/feedFiltersInit.ts b/frontend/src/components/feedFiltersInit.ts index fe0f9594..d9eb0ccd 100644 --- a/frontend/src/components/feedFiltersInit.ts +++ b/frontend/src/components/feedFiltersInit.ts @@ -18,6 +18,9 @@ export const DEFAULT_FILTERS: FeedFilters = { show: "unwatched", }; +/** The watch-state choices, in the order the filter offers them. */ +export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; + export const FILTERS_KEY = LS.filters; // This account's own persisted filters (per-account keys — never another account's). No account diff --git a/frontend/src/components/ui/NavRail.dom.test.tsx b/frontend/src/components/ui/NavRail.dom.test.tsx index cd0b9b70..0ed36d31 100644 --- a/frontend/src/components/ui/NavRail.dom.test.tsx +++ b/frontend/src/components/ui/NavRail.dom.test.tsx @@ -6,7 +6,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { Bell } from "lucide-react"; import { NavRailAction, NavRailItem } from "./NavRail"; @@ -24,10 +24,11 @@ describe("NavRailItem", () => { showLabel={false} active badge={3} + href="/?page=notifications" onClick={() => {}} />, ); - const row = screen.getByRole("button", { name: "Notifications (3)" }); + const row = screen.getByRole("link", { name: "Notifications (3)" }); expect(row.getAttribute("aria-current")).toBe("page"); expect(row.className).toContain("bg-accent/15"); expect(row.textContent).not.toContain("Alerts"); @@ -40,10 +41,11 @@ describe("NavRailItem", () => { label="Notifications" shortLabel="Alerts" showLabel + href="/?page=notifications" onClick={() => {}} />, ); - const row = screen.getByRole("button", { name: "Alerts" }); + const row = screen.getByRole("link", { name: "Alerts" }); expect(row.getAttribute("aria-current")).toBeNull(); }); @@ -57,10 +59,51 @@ describe("NavRailItem", () => { shortLabel="Alerts" showLabel badge={20} + href="/?page=notifications" onClick={() => {}} />, ); - expect(screen.getByRole("button", { name: "Alerts (20)" })).toBeTruthy(); + expect(screen.getByRole("link", { name: "Alerts (20)" })).toBeTruthy(); + }); + + // E31 (the owner): a middle-click on a rail row opens the page in a new tab. The row is a real link + // to the page, so the browser's own gestures work; only a plain left click navigates in-app. + it("is a link to its page, navigating in-app only on a plain left click", () => { + const onClick = vi.fn(); + render( + , + ); + const row = screen.getByRole("link", { name: "Notifications" }); + expect(row.getAttribute("href")).toBe("/?page=notifications"); + // Whether the row left the click to the browser, read after the app's own handler ran; then the + // click is stopped here, as jsdom cannot follow a link. + const leftToBrowser: boolean[] = []; + const record = (e: Event) => { + leftToBrowser.push(!e.defaultPrevented); + e.preventDefault(); + }; + window.addEventListener("click", record); + try { + fireEvent.click(row, { button: 0 }); + expect(onClick).toHaveBeenCalledTimes(1); + for (const init of [ + { button: 1 }, + { button: 0, ctrlKey: true }, + { button: 0, metaKey: true }, + ]) + fireEvent.click(row, init); + } finally { + window.removeEventListener("click", record); + } + expect(leftToBrowser).toEqual([false, true, true, true]); + expect(onClick).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/src/components/ui/NavRail.tsx b/frontend/src/components/ui/NavRail.tsx index 0ce9e85d..04b65eb8 100644 --- a/frontend/src/components/ui/NavRail.tsx +++ b/frontend/src/components/ui/NavRail.tsx @@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react"; import { Pressable } from "./Pressable"; import { SURFACE } from "./surfaces"; import Tooltip from "./Tooltip"; +import { isPlainLeftClick } from "../../lib/nav"; // B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the // labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px @@ -76,6 +77,7 @@ export function NavRailItem({ showLabel, active = false, badge, + href, onClick, testId, }: { @@ -88,15 +90,24 @@ export function NavRailItem({ active?: boolean; /** An unread / in-progress count on the icon's corner (0 hides it). */ badge?: number; + /** The page's own address. A page row is a real link (the owner, E31): a middle-click, a + * Ctrl/Cmd-click or the browser's "Open in new tab" opens the page in a tab of its own. */ + href: string; + /** The in-app navigation, run only for a plain left click. */ onClick: () => void; testId?: string; }) { const shown = !!badge && badge > 0; return ( - { + if (!isPlainLeftClick(e)) return; // the browser opens its tab or window + e.preventDefault(); + onClick(); + }} // With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name // contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the // name. The exact count rides in the name either way, so "Messages (3)" is heard and not only @@ -131,7 +142,7 @@ export function NavRailItem({ {shortLabel} )} - + ); } diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 1b1d270a..6164121b 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -46,7 +46,7 @@ const PLAIN: Record = { "ui/ActionMenu.tsx": [1, "a menu row"], "ui/BackToTop.tsx": [1, "a control"], "ui/CommandPalette.tsx": [1, "a command row"], - "ui/NavRail.tsx": [2, "nav items"], + "ui/NavRail.tsx": [1, "the action rows (About, Sign out); a page row is a link (E31)"], "ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"], "ui/SidePanel.tsx": [2, "panel controls"], "ui/tableHeader.tsx": [1, "a column's sort header"], diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index 82df2382..f4513bd8 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -19,5 +19,7 @@ "actor": "Who", "action": "Action", "details": "Details" - } + }, + "earlierRuns_one": "{{count}} earlier run", + "earlierRuns_other": "{{count}} earlier runs" } diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index f07a73bc..70c4064b 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -101,5 +101,6 @@ "setPassword": "Create a set-password link", "setPasswordAgain": "Create another link", "linkFailed": "Couldn't create the link. Please try again." - } + }, + "rowMenu": "User actions" } diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 907042c2..294ce05e 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -19,5 +19,7 @@ "actor": "Ki", "action": "Művelet", "details": "Részletek" - } + }, + "earlierRuns_one": "{{count}} korábbi futás", + "earlierRuns_other": "{{count}} korábbi futás" } diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index b9718050..b897a21c 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -101,5 +101,6 @@ "setPassword": "Jelszóbeállító link készítése", "setPasswordAgain": "Új link készítése", "linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra." - } + }, + "rowMenu": "Felhasználó műveletei" } diff --git a/frontend/src/lib/auditGroups.test.ts b/frontend/src/lib/auditGroups.test.ts new file mode 100644 index 00000000..ff505de3 --- /dev/null +++ b/frontend/src/lib/auditGroups.test.ts @@ -0,0 +1,60 @@ +// E31: the audit log's first screen was one job's "done" ten times over. Back-to-back successful runs +// of one job fold into one row; a failure, a skip, another job or an admin's own action breaks the run. +import { describe, expect, it } from "vitest"; +import type { AuditRow } from "./api"; +import { foldJobRuns } from "./auditGroups"; + +let nextId = 1; +const run = (job: string, status = "ok"): AuditRow => ({ + id: nextId++, + created_at: null, + actor: null, + action: "job_run", + target_type: "job", + target_id: job, + summary: null, + before: null, + after: { status }, +}); +const admin = (): AuditRow => ({ ...run("x"), action: "config_set", target_type: "config" }); + +const shape = (rows: ReturnType) => + rows.map((r) => `${r.target_id}:${String(r.after?.status)}×${r.runs?.length ?? 1}`); + +describe("folding the job runs", () => { + it("folds back-to-back successful runs of one job, newest first", () => { + const rows = [run("enrich"), run("enrich"), run("enrich"), run("people")]; + const out = foldJobRuns(rows); + expect(shape(out)).toEqual(["enrich:ok×3", "people:ok×1"]); + expect(out[0]!.id).toBe(rows[0]!.id); + expect(out[0]!.runs!.map((r) => r.id)).toEqual(rows.slice(0, 3).map((r) => r.id)); + }); + + it("never folds a failed or skipped run, and lets it break the run", () => { + const rows = [run("rss"), run("rss", "error"), run("rss", "error"), run("rss"), run("rss")]; + expect(shape(foldJobRuns(rows))).toEqual([ + "rss:ok×1", + "rss:error×1", + "rss:error×1", + "rss:ok×2", + ]); + expect(shape(foldJobRuns([run("rss", "skipped"), run("rss", "skipped")]))).toEqual([ + "rss:skipped×1", + "rss:skipped×1", + ]); + }); + + it("keeps an admin's own action between two runs, and the runs apart", () => { + expect(shape(foldJobRuns([run("enrich"), admin(), run("enrich")]))).toEqual([ + "enrich:ok×1", + "x:ok×1", + "enrich:ok×1", + ]); + }); + + it("leaves the rows it was given as they were", () => { + const rows = [run("enrich"), run("enrich")]; + foldJobRuns(rows); + expect("runs" in rows[0]!).toBe(false); + }); +}); diff --git a/frontend/src/lib/auditGroups.ts b/frontend/src/lib/auditGroups.ts new file mode 100644 index 00000000..6fe39b81 --- /dev/null +++ b/frontend/src/lib/auditGroups.ts @@ -0,0 +1,32 @@ +import type { AuditRow } from "./api"; + +// The audit log's scheduler noise, folded (E31, the owner's pick): the background jobs write a row per +// run that changed something, so an hour of "enrichment done" filled the first screen and pushed the +// admins' own actions down. Back-to-back SUCCESSFUL runs of the same job become one row that names how +// many there were and opens to each; a failed or skipped run is never folded — it stays a row of its +// own, where it is seen. Rows arrive newest first; the group stands at its newest run's place. + +/** An audit row as the table lists it: a lone row, or the newest of a run of folded ones (`runs`, the + * whole run newest first, the row itself included). */ +export type AuditListRow = AuditRow & { runs?: AuditRow[] }; + +/** The job a row is a successful run of, or null for any other row. */ +function okRunOf(row: AuditRow): string | null { + if (row.action !== "job_run" || row.target_type !== "job" || !row.target_id) return null; + return row.after?.status === "ok" ? row.target_id : null; +} + +export function foldJobRuns(rows: readonly AuditRow[]): AuditListRow[] { + const out: AuditListRow[] = []; + let head: AuditListRow | null = null; + for (const row of rows) { + const job = okRunOf(row); + if (job !== null && head !== null && okRunOf(head) === job) { + head.runs = [...(head.runs ?? [head]), row]; + continue; + } + head = { ...row }; + out.push(head); + } + return out; +} diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts index 1bfcd6ff..53716af5 100644 --- a/frontend/src/lib/nav.ts +++ b/frontend/src/lib/nav.ts @@ -17,6 +17,13 @@ export function navigateTo(p: Page): void { _navigate?.(p); } +/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's + * state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage, + * as a bookmark would; the CURRENT page's params would be the wrong page's state. */ +export function pageHref(p: Page): string { + return `/?page=${p}`; +} + /** Should this click be handled IN-APP, or left to the browser? * * An anchor that ALSO navigates in-app has to keep the browser's own gestures working — Ctrl/Cmd-click diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index adc1b7b6..264cf7a5 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -35,6 +35,7 @@ export const LS = { channelLayout: "siftlode.channelLayout", // The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab). channelSort: "siftlode.channelSort", + channelShow: "siftlode.channelShow", channelDiscoverySort: "siftlode.channelDiscoverySort", // Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob). channelCardSize: "siftlode.channelCardSize", From 25905aa786ef32a60c3f90a6ecffbbf3d6a77737 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 23:05:38 +0200 Subject: [PATCH 05/31] fix(audit): fold a job's successful runs whatever ran between them The owner read a fold that broke at every other job as the same row five times. A job's successful runs now fold into one row at the newest run's place across other jobs and admin actions; only that job's own failed or skipped run breaks the fold (it stays its own row, and older successes start a new fold below it). --- docs/DESIGN.md | 2 +- frontend/src/lib/auditGroups.test.ts | 26 +++++++++++----- frontend/src/lib/auditGroups.ts | 44 +++++++++++++++++++--------- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d128c257..a82749f6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -695,7 +695,7 @@ of a full-width panel and nothing on the right. | 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance | | 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight | | 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick | -| 2026-10-01 | E31 Audit log runs | Back-to-back successful runs of one job fold into one row that opens; a failure is never folded and reads in the danger colour | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen | +| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen | | 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu | | 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible | | 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page | diff --git a/frontend/src/lib/auditGroups.test.ts b/frontend/src/lib/auditGroups.test.ts index ff505de3..a4f6a7c3 100644 --- a/frontend/src/lib/auditGroups.test.ts +++ b/frontend/src/lib/auditGroups.test.ts @@ -1,5 +1,6 @@ -// E31: the audit log's first screen was one job's "done" ten times over. Back-to-back successful runs -// of one job fold into one row; a failure, a skip, another job or an admin's own action breaks the run. +// E31: the audit log's first screen was one job's "done" ten times over. A job's successful runs fold +// into one row whatever ran between them (the owner: a fold broken by every other job read as the same +// row five times); only that job's own failure or skip breaks it. import { describe, expect, it } from "vitest"; import type { AuditRow } from "./api"; import { foldJobRuns } from "./auditGroups"; @@ -22,7 +23,7 @@ const shape = (rows: ReturnType) => rows.map((r) => `${r.target_id}:${String(r.after?.status)}×${r.runs?.length ?? 1}`); describe("folding the job runs", () => { - it("folds back-to-back successful runs of one job, newest first", () => { + it("folds a job's successful runs into one row, newest first", () => { const rows = [run("enrich"), run("enrich"), run("enrich"), run("people")]; const out = foldJobRuns(rows); expect(shape(out)).toEqual(["enrich:ok×3", "people:ok×1"]); @@ -44,11 +45,22 @@ describe("folding the job runs", () => { ]); }); - it("keeps an admin's own action between two runs, and the runs apart", () => { - expect(shape(foldJobRuns([run("enrich"), admin(), run("enrich")]))).toEqual([ - "enrich:ok×1", - "x:ok×1", + it("folds across other jobs and an admin's own action, each at its own place", () => { + const rows = [run("enrich"), run("plex"), run("enrich"), admin(), run("shorts"), run("enrich")]; + rows.push(run("plex")); + const out = foldJobRuns(rows); + expect(shape(out)).toEqual(["enrich:ok×3", "plex:ok×2", "x:ok×1", "shorts:ok×1"]); + expect(out[0]!.runs!.map((r) => r.id)).toEqual([rows[0]!.id, rows[2]!.id, rows[5]!.id]); + }); + + it("starts a new fold below the job's own failure, which another job's does not", () => { + const rows = [run("enrich"), run("enrich", "error"), run("enrich"), run("rss", "error")]; + rows.push(run("enrich")); + expect(shape(foldJobRuns(rows))).toEqual([ "enrich:ok×1", + "enrich:error×1", + "enrich:ok×2", + "rss:error×1", ]); }); diff --git a/frontend/src/lib/auditGroups.ts b/frontend/src/lib/auditGroups.ts index 6fe39b81..ace58e36 100644 --- a/frontend/src/lib/auditGroups.ts +++ b/frontend/src/lib/auditGroups.ts @@ -2,31 +2,47 @@ import type { AuditRow } from "./api"; // The audit log's scheduler noise, folded (E31, the owner's pick): the background jobs write a row per // run that changed something, so an hour of "enrichment done" filled the first screen and pushed the -// admins' own actions down. Back-to-back SUCCESSFUL runs of the same job become one row that names how -// many there were and opens to each; a failed or skipped run is never folded — it stays a row of its -// own, where it is seen. Rows arrive newest first; the group stands at its newest run's place. +// admins' own actions down. A job's SUCCESSFUL runs become one row that names how many there were and +// opens to each — whatever ran between them (another job, an admin's action): the owner read a fold +// that broke at every other job as the same row five times. Only that job's own failed or skipped run +// breaks it: that row stays on its own, at its moment, where it is seen, and the job's older successes +// start a new fold below it. Rows arrive newest first; a fold stands at its newest run's place. -/** An audit row as the table lists it: a lone row, or the newest of a run of folded ones (`runs`, the - * whole run newest first, the row itself included). */ +/** An audit row as the table lists it: a lone row, or the newest of a job's folded runs (`runs`, the + * whole fold newest first, the row itself included). */ export type AuditListRow = AuditRow & { runs?: AuditRow[] }; -/** The job a row is a successful run of, or null for any other row. */ -function okRunOf(row: AuditRow): string | null { - if (row.action !== "job_run" || row.target_type !== "job" || !row.target_id) return null; - return row.after?.status === "ok" ? row.target_id : null; +/** The job a row is a run of, or null for any other row. */ +function jobOf(row: AuditRow): string | null { + return row.action === "job_run" && row.target_type === "job" && row.target_id + ? row.target_id + : null; } export function foldJobRuns(rows: readonly AuditRow[]): AuditListRow[] { const out: AuditListRow[] = []; - let head: AuditListRow | null = null; + // Each job's open fold: the row its next older success joins. + const open = new Map(); for (const row of rows) { - const job = okRunOf(row); - if (job !== null && head !== null && okRunOf(head) === job) { + const job = jobOf(row); + if (job === null) { + out.push(row); + continue; + } + if (row.after?.status !== "ok") { + // A failure or a skip stands alone and closes the job's fold. + open.delete(job); + out.push(row); + continue; + } + const head = open.get(job); + if (head) { head.runs = [...(head.runs ?? [head]), row]; continue; } - head = { ...row }; - out.push(head); + const fresh: AuditListRow = { ...row }; + open.set(job, fresh); + out.push(fresh); } return out; } From be156fc52bf82f0a39bd126d038c6c00bff506de Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 00:07:00 +0200 Subject: [PATCH 06/31] =?UTF-8?q?feat(ui):=20E31=20S4b=20=E2=80=94=20the?= =?UTF-8?q?=20admin=20pages=20behind=20one=20rail=20row,=20Configuration?= =?UTF-8?q?=20as=20a=20sectioned=20list,=20Statistics=20as=20tiles=20and?= =?UTF-8?q?=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll - sysconfig.SECTIONS orders the config groups under four headings (every group in exactly one, test-held); the page shows them as Settings' side list (Tabs `group` headings); the lone registration group reads "Registration" under "Access" - StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the per-operation quota use as horizontal bars (a zero draws none) --- backend/app/sysconfig.py | 19 +- backend/tests/test_sysconfig_registry.py | 25 +- .../ConfigPanel.choice.dom.test.tsx | 1 + .../components/ConfigPanel.field.dom.test.tsx | 1 + .../ConfigPanel.sections.dom.test.tsx | 92 +++++++ frontend/src/components/ConfigPanel.tsx | 247 +++++++++--------- frontend/src/components/NavSidebar.tsx | 25 +- frontend/src/components/Stats.tsx | 110 ++++---- .../src/components/ui/NavRail.dom.test.tsx | 87 +++++- frontend/src/components/ui/NavRail.tsx | 116 +++++++- frontend/src/components/ui/README.md | 14 +- .../src/components/ui/StatTiles.dom.test.tsx | 46 ++++ frontend/src/components/ui/StatTiles.tsx | 85 ++++++ frontend/src/components/ui/Tabs.tsx | 79 +++--- frontend/src/components/ui/form.tsx | 2 +- .../ui/pressableSelectable.contract.test.ts | 5 +- frontend/src/i18n/locales/en/config.json | 8 +- frontend/src/i18n/locales/en/nav.json | 2 + frontend/src/i18n/locales/hu/config.json | 8 +- frontend/src/i18n/locales/hu/nav.json | 2 + frontend/src/lib/api/admin.ts | 2 + 21 files changed, 753 insertions(+), 223 deletions(-) create mode 100644 frontend/src/components/ConfigPanel.sections.dom.test.tsx create mode 100644 frontend/src/components/ui/StatTiles.dom.test.tsx create mode 100644 frontend/src/components/ui/StatTiles.tsx diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index d8e3f407..a1ddc43b 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -45,8 +45,8 @@ class ConfigSpec: # Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it -# off env into the admin UI; the admin page renders it automatically from this list. The ORDER is the -# admin page's tab order (first appearance of each group), so keep a group's keys together. +# off env into the admin UI; the admin page renders it automatically from this list. A group's keys +# stay together, in the order its section lists them; a NEW group also needs its place in SECTIONS. SPECS: tuple[ConfigSpec, ...] = ( # --- Access / registration --- ConfigSpec("allow_registration", "bool", "access", "allow_registration"), @@ -219,6 +219,16 @@ SPECS: tuple[ConfigSpec, ...] = ( # is a config.py default — both stay out of SPECS, like plex_sync_interval_min.) ) +# The admin page's sections (E31, the owner's pick): sixteen groups as tabs wrapped to three rows at 133% +# zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in +# exactly one section (test-held), so a new group cannot fall off the page. +SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("access", ("access", "google", "email")), + ("youtube", ("youtube", "quota", "backfill", "shorts", "batch", "explore")), + ("media", ("downloads", "plex", "tmdb")), + ("system", ("maintenance", "audit", "diagnostics", "issues")), +) + _BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS} @@ -429,4 +439,7 @@ def describe(db: Session) -> dict: item["value"] = get(db, s.key) item["default"] = _default(s) groups.setdefault(s.group, []).append(item) - return {"groups": groups, "secrets_manageable": secrets_manageable()} + sections = [ + {"id": sid, "groups": [g for g in members if g in groups]} for sid, members in SECTIONS + ] + return {"groups": groups, "sections": sections, "secrets_manageable": secrets_manageable()} diff --git a/backend/tests/test_sysconfig_registry.py b/backend/tests/test_sysconfig_registry.py index 7a25e5d6..d933ec30 100644 --- a/backend/tests/test_sysconfig_registry.py +++ b/backend/tests/test_sysconfig_registry.py @@ -1,8 +1,10 @@ -"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE. +"""The sysconfig registry: a group's keys stay together, every group has its section, and a closed-set +knob is a CHOICE. The admin Configuration page used to keep its own hand-written group order, which had already drifted -(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in -tab order: one run of keys per group. +(`explore` was missing, so its tab sorted last). The page now follows the registry: SECTIONS orders the +groups under their headings (E31), and a group missing there would fall off the page — so each group +of SPECS sits in exactly one section. A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up). @@ -22,7 +24,7 @@ from app.config import Settings, choices_of CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"} -def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order(): +def test_each_group_is_one_run_of_keys(): runs = [ s.group for i, s in enumerate(sysconfig.SPECS) @@ -32,6 +34,13 @@ def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order(): assert runs[:2] == ["access", "email"] +def test_every_group_sits_in_exactly_one_section(): + listed = [g for _, members in sysconfig.SECTIONS for g in members] + assert len(listed) == len(set(listed)), listed + assert set(listed) == {s.group for s in sysconfig.SPECS} + assert [sid for sid, _ in sysconfig.SECTIONS] == ["access", "youtube", "media", "system"] + + def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config(): assert CHOICE_KEYS == { "search_source", @@ -85,6 +94,14 @@ def test_describe_hands_the_picker_its_options(): assert items["quota_daily_budget"]["choices"] == [] +def test_describe_lists_every_group_under_its_section_in_order(): + d = sysconfig.describe(_FakeDb()) + assert [s["id"] for s in d["sections"]] == ["access", "youtube", "media", "system"] + assert d["sections"][0]["groups"] == ["access", "google", "email"] + listed = [g for s in d["sections"] for g in s["groups"]] + assert sorted(listed) == sorted(d["groups"]) + + def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db): sysconfig.set_value(db, "search_source", "API") assert sysconfig.get_str(db, "search_source") == "api" diff --git a/frontend/src/components/ConfigPanel.choice.dom.test.tsx b/frontend/src/components/ConfigPanel.choice.dom.test.tsx index d22dab5f..cc7b8ff1 100644 --- a/frontend/src/components/ConfigPanel.choice.dom.test.tsx +++ b/frontend/src/components/ConfigPanel.choice.dom.test.tsx @@ -72,6 +72,7 @@ vi.mock("../lib/api", async (importOriginal) => { choice("log_level", LEVELS, "INFO"), ], }, + sections: [{ id: "s", groups: ["g"] }], secrets_manageable: true, }), setConfig: (key: string, value: unknown) => setConfig(key, value), diff --git a/frontend/src/components/ConfigPanel.field.dom.test.tsx b/frontend/src/components/ConfigPanel.field.dom.test.tsx index 9d697748..49e1c407 100644 --- a/frontend/src/components/ConfigPanel.field.dom.test.tsx +++ b/frontend/src/components/ConfigPanel.field.dom.test.tsx @@ -46,6 +46,7 @@ vi.mock("../lib/api", async (importOriginal) => { groups: { g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }], }, + sections: [{ id: "s", groups: ["g"] }], secrets_manageable: true, }), }, diff --git a/frontend/src/components/ConfigPanel.sections.dom.test.tsx b/frontend/src/components/ConfigPanel.sections.dom.test.tsx new file mode 100644 index 00000000..388634cf --- /dev/null +++ b/frontend/src/components/ConfigPanel.sections.dom.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +// +// E31 (the owner's pick): Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they +// are a side list under four headings, as Settings has. The registry owns the order: the groups follow +// its sections, not the order the groups object happens to arrive in. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ConfigItem } from "../lib/api"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +const text = (key: string, group: string): ConfigItem => ({ + key, + type: "str", + group, + secret: false, + min: null, + max: null, + is_set: false, + allow_empty: true, + choices: [], + value: "", + default: "", +}); + +vi.mock("../lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + adminConfig: () => + Promise.resolve({ + groups: { b: [text("kb", "b")], c: [text("kc", "c")], a: [text("ka", "a")] }, + sections: [ + { id: "one", groups: ["a", "b"] }, + { id: "two", groups: ["c"] }, + ], + secrets_manageable: true, + }), + }, + }; +}); +vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) })); + +import ConfigPanel from "./ConfigPanel"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +const renderPanel = () => + render( + + + , + ); + +describe("Configuration sections (E31)", () => { + it("lists the groups in the registry's sections, each heading once above its run", async () => { + renderPanel(); + const list = await screen.findByRole("tablist"); + expect(list.getAttribute("aria-orientation")).toBe("vertical"); + // The list's own lines, top to bottom: headings and tabs. + const lines = Array.from(list.children).map((el) => + el.getAttribute("role") === "tab" ? `tab:${el.textContent}` : `head:${el.textContent}`, + ); + expect(lines).toEqual([ + "head:config.sections.one", + "tab:config.groups.a", + "tab:config.groups.b", + "head:config.sections.two", + "tab:config.groups.c", + ]); + }); + + it("opens on the first group and switches to the one picked", async () => { + renderPanel(); + expect(await screen.findByText("config.fields.ka.label")).toBeTruthy(); + fireEvent.click(screen.getByRole("tab", { name: "config.groups.c" })); + expect(screen.getByText("config.fields.kc.label")).toBeTruthy(); + expect(screen.queryByText("config.fields.ka.label")).toBeNull(); + }); +}); diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index ddbdf971..bc43693f 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -11,7 +11,6 @@ import { useMe } from "../lib/useMe"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { LoadingState, StateMessage } from "./ui/QueryState"; -import { PageToolbar } from "./ui/PageShell"; import { Switch } from "./ui/form"; import SegmentedToggle from "./ui/SegmentedToggle"; import FilterSelect from "./ui/FilterSelect"; @@ -176,146 +175,152 @@ export default function ConfigPanel() { return ; } - const groupKeys = Object.keys(data.groups); + // The groups in the registry's sections, in order (E31: sixteen tabs wrapped to three rows at 133% + // zoom, so they are a side list under four headings, as Settings has). + const groupKeys = data.sections.flatMap((s) => s.groups); // Clamp the persisted tab to a real group (the registry can change between sessions). The // tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is // never silently lost behind the global Save bar. const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0]; const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean)); - const groupTabs = groupKeys.map((g) => ({ - id: g, - label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`, - })); + const groupTabs = data.sections.flatMap((s) => + s.groups.map((g) => ({ + id: g, + group: t(`config.sections.${s.id}`, s.id), + label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`, + testId: `config-tab-${g}`, + })), + ); return ( <> - {/* The tab bar is the page's band (B4); the intro scrolls with the fields — in the band it - took three lines of a short window for text read once. */} - -
+
+

{t("config.intro")}

+ {/* The groups as a side list under their headings, the fields beside it: Settings' own + layout, one pattern for both settings pages (E31). */} +
-
- -
-

{t("config.intro")}

- {activeGroup && ( -
-
- {(data.groups[activeGroup] ?? []).map((item) => ( - setDraft((d) => ({ ...d, [item.key]: v }))} - onResetField={() => { - if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] })); - else setDraft((d) => ({ ...d, [item.key]: "" })); - }} - /> - ))} -
+ {activeGroup && ( +
+
+ {(data.groups[activeGroup] ?? []).map((item) => ( + setDraft((d) => ({ ...d, [item.key]: v }))} + onResetField={() => { + if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] })); + else setDraft((d) => ({ ...d, [item.key]: "" })); + }} + /> + ))} +
- {activeGroup === "email" && ( -
- {/* The one place an operator can see that mail is off. Sending is fail-soft, so a + {activeGroup === "email" && ( +
+ {/* The one place an operator can see that mail is off. Sending is fail-soft, so a half-filled config costs verification links, resets and access-request notices with no error anywhere — say so next to the fields that fix it. */} - {!me.email_configured && {t("config.emailOff")}} - - - - {dirty &&

{t("config.testDirty")}

} -
- )} + {!me.email_configured && {t("config.emailOff")}} + + + + {dirty &&

{t("config.testDirty")}

} +
+ )} - {activeGroup === "issues" && ( -
- - - - {dirty &&

{t("config.testDirty")}

} -
- )} + {activeGroup === "issues" && ( +
+ + + + {dirty &&

{t("config.testDirty")}

} +
+ )} - {activeGroup === "plex" && ( -
- - - - {dirty &&

{t("config.testDirty")}

} - {plexResult && ( -
-

- {t("config.plexConnected", { - name: plexResult.server_name, - version: plexResult.version ?? "", - })} -

- {plexResult.media_check?.checked && - (plexResult.media_check.ok ? ( -

{t("config.plexMediaOk")}

- ) : ( -

- {t("config.plexMediaMissing", { - path: plexResult.media_check.local_path ?? "", - })} -

- ))} -

{t("config.plexPickLibraries")}

-
- {plexResult.sections.map((s) => ( - togglePlexLib(s.key)} - className="text-sm" - > - {s.title} - - {s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")} - {s.count != null ? ` · ${s.count}` : ""} - - - ))} + {activeGroup === "plex" && ( +
+ + + + {dirty &&

{t("config.testDirty")}

} + {plexResult && ( +
+

+ {t("config.plexConnected", { + name: plexResult.server_name, + version: plexResult.version ?? "", + })} +

+ {plexResult.media_check?.checked && + (plexResult.media_check.ok ? ( +

{t("config.plexMediaOk")}

+ ) : ( +

+ {t("config.plexMediaMissing", { + path: plexResult.media_check.local_path ?? "", + })} +

+ ))} +

{t("config.plexPickLibraries")}

+
+ {plexResult.sections.map((s) => ( + togglePlexLib(s.key)} + className="text-sm" + > + {s.title} + + {s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")} + {s.count != null ? ` · ${s.count}` : ""} + + + ))} +
+

{t("config.plexPickHint")}

-

{t("config.plexPickHint")}

-
- )} -
- )} -
- )} + )} +
+ )} +
+ )} +
{userItems.map(renderItem)} - {systemItems.length > 0 && } - {systemItems.map(renderItem)} + {systemItems.length > 0 && ( + <> + + {/* The admin pages, one row opening a panel of them (E31): five rows of their own sat + below the fold at 133% zoom, behind a fade alone. */} + ({ + id: p, + icon, + label, + href: pageHref(p), + active: page === p, + onSelect: () => setPage(p), + }))} + /> + + )}
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index ab825c4d..ad70f22d 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -15,7 +15,9 @@ import FilterChipGroup from "./ui/FilterChipGroup"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { PageToolbar } from "./ui/PageShell"; -import { Section, SettingRow } from "./ui/form"; +import { Section } from "./ui/form"; +import { SectionLabel } from "./ui/Headings"; +import { StatBars, StatTile, StatTiles } from "./ui/StatTiles"; import { useMe } from "../lib/useMe"; import { notifyActionError } from "../lib/actionErrors"; import { Button } from "./ui/Button"; @@ -108,34 +110,40 @@ function Overview({ me }: { me: Me }) { <>
{s ? ( -
- - {s.channels_total} - - + + - {`${s.channels_recent_synced}/${s.channels_total}`} - - - {`${s.channels_deep_done}/${s.channels_deep_requested}`} - + value={`${formatNumber(s.channels_recent_synced, lang)}/${formatNumber(s.channels_total, lang)}`} + /> + {s.deep_pending_count > 0 && ( - - {formatEta(t, s.deep_eta_seconds)} - + value={formatEta(t, s.deep_eta_seconds)} + /> )} - - {formatNumber(s.my_videos, lang)} - - - {formatNumber(s.quota_remaining_today, lang)} - -
+ + + ) : status.isError ? ( status.refetch()}> {t("common.loadError")} @@ -148,31 +156,39 @@ function Overview({ me }: { me: Me }) {
{usage.data ? ( <> -
- - {formatNumber(usage.data.today, lang)} - - - {formatNumber(usage.data.last_7d, lang)} - - - {formatNumber(usage.data.last_30d, lang)} - - - {formatNumber(usage.data.all_time, lang)} - -
+ + + + + + {Object.keys(usage.data.by_action).length > 0 && ( -
-
{t("stats.sync.byAction")}
- {Object.entries(usage.data.by_action) - .sort((a, b) => b[1] - a[1]) - .map(([action, units]) => ( -
- {quotaActionLabel(t, action)} - {formatNumber(units, lang)} -
- ))} +
+ + {t("stats.sync.byAction")} + + formatNumber(n, lang)} + rows={Object.entries(usage.data.by_action).map(([action, units]) => ({ + id: action, + label: quotaActionLabel(t, action), + value: units, + }))} + />
)} diff --git a/frontend/src/components/ui/NavRail.dom.test.tsx b/frontend/src/components/ui/NavRail.dom.test.tsx index 0ed36d31..c5dac0fc 100644 --- a/frontend/src/components/ui/NavRail.dom.test.tsx +++ b/frontend/src/components/ui/NavRail.dom.test.tsx @@ -7,8 +7,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { Bell } from "lucide-react"; -import { NavRailAction, NavRailItem } from "./NavRail"; +import { Activity, Bell, Shield, Users } from "lucide-react"; +import { NavRailAction, NavRailGroup, NavRailItem } from "./NavRail"; vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} })); @@ -123,3 +123,86 @@ describe("NavRailAction", () => { expect(src).not.toMatch(/]/); }); }); + +// E31 (the owner's pick): the five admin rows sat below the fold at 133% zoom behind a fade alone, so +// they are one "Admin" row opening a panel of them. The panel's pages stay links, as the rail's are. +describe("NavRailGroup", () => { + const pages = (active: string | null, onSelect = vi.fn()) => + [ + { id: "scheduler", icon: Activity, label: "Scheduler" }, + { id: "users", icon: Users, label: "Users" }, + ].map((p) => ({ ...p, href: `/?page=${p.id}`, active: p.id === active, onSelect })); + + it("is one row, current while any of its pages is, opening a panel of links", () => { + render( + , + ); + const row = screen.getByRole("button", { name: "Administration" }); + expect(row.getAttribute("aria-current")).toBe("page"); + expect(screen.queryByRole("link")).toBeNull(); + fireEvent.click(row); + // jsdom never positions the panel, so it stays visibility:hidden and nameless to the role query. + const panel = screen.getByRole("dialog", { hidden: true }); + expect(panel.getAttribute("aria-label")).toBe("Administration"); + const links = Array.from(panel.querySelectorAll("a")); + expect(links.map((a) => [a.textContent, a.getAttribute("href")])).toEqual([ + ["Scheduler", "/?page=scheduler"], + ["Users", "/?page=users"], + ]); + expect(links.map((a) => a.getAttribute("aria-current"))).toEqual([null, "page"]); + }); + + it("is not current while none of its pages is", () => { + render( + , + ); + const row = screen.getByRole("button", { name: "Admin" }); + expect(row.getAttribute("aria-current")).toBeNull(); + expect(row.className).not.toContain("bg-accent/15"); + }); + + it("a plain click goes to the page and closes the panel; a middle or Ctrl click is the browser's", () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Administration" })); + const link = () => + screen.getByRole("dialog", { hidden: true }).querySelector("a")!; + const leftToBrowser: boolean[] = []; + const record = (e: Event) => { + leftToBrowser.push(!e.defaultPrevented); + e.preventDefault(); + }; + window.addEventListener("click", record); + try { + fireEvent.click(link(), { button: 1 }); + fireEvent.click(link(), { button: 0, ctrlKey: true }); + expect(onSelect).not.toHaveBeenCalled(); + fireEvent.click(link(), { button: 0 }); + } finally { + window.removeEventListener("click", record); + } + expect(leftToBrowser).toEqual([true, true, false]); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog", { hidden: true })).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/NavRail.tsx b/frontend/src/components/ui/NavRail.tsx index 04b65eb8..17446264 100644 --- a/frontend/src/components/ui/NavRail.tsx +++ b/frontend/src/components/ui/NavRail.tsx @@ -1,6 +1,8 @@ import clsx from "clsx"; -import type { CSSProperties, ReactNode, Ref } from "react"; +import { useState, type CSSProperties, type ReactNode, type Ref } from "react"; import type { LucideIcon } from "lucide-react"; +import { PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { Pressable } from "./Pressable"; import { SURFACE } from "./surfaces"; import Tooltip from "./Tooltip"; @@ -171,3 +173,115 @@ export function NavRailAction({ ); } + +/** One page of a `NavRailGroup`'s panel. */ +export interface NavRailGroupPage { + id: string; + icon: LucideIcon; + /** The page's full name, written out in the panel. */ + label: string; + href: string; + active: boolean; + /** The in-app navigation, run only for a plain left click. */ + onSelect: () => void; +} + +/** One rail row standing for a set of rare pages (E31, the owner's pick: the admin pages), opening a + * panel of them to its right. The rail fits a 133–150% zoom without a scroll nobody sees: five admin + * rows sat below the fold behind a fade alone. The row is "you are here" while any of its pages is. + * The panel's pages are real links, as the rail's own rows are (a middle-click opens a tab), so it is a + * `dialog` popover with its own Tab order, not a roving menu of buttons. */ +export function NavRailGroup({ + icon: Icon, + label, + shortLabel, + showLabel, + pages, + testId, +}: { + icon: LucideIcon; + /** The group's full name: the tooltip, the row's and the panel's accessible name, the panel heading. */ + label: string; + shortLabel: string; + showLabel: boolean; + pages: readonly NavRailGroupPage[]; + testId?: string; +}) { + const [open, setOpen] = useState(false); + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + mode: "dialog", + role: "dialog", + placement: "right-start", + offset: 8, + }); + const active = pages.some((p) => p.active); + return ( + <> + + + {active && ( + + + +
+ {label} +
+ +
+ + ); +} diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 7c85e7d9..370e65de 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -36,13 +36,13 @@ domain-free. ## What lives here -| area | components | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | -| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw ` + + {/* A heading, not a tab: no role a tab list would have to own, its text still read. Only + on the side rail; the phone's scrolling row has no room for it. */} + {heading && ( +
+ {heading} +
+ )} + +
); })}
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index 1ff23784..3102c346 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -72,7 +72,7 @@ export function Section({ /** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set. * No-op styling when there's no hint, so it's safe to use unconditionally. */ -function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) { +export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) { return ( = { "ui/ActionMenu.tsx": [1, "a menu row"], "ui/BackToTop.tsx": [1, "a control"], "ui/CommandPalette.tsx": [1, "a command row"], - "ui/NavRail.tsx": [1, "the action rows (About, Sign out); a page row is a link (E31)"], + "ui/NavRail.tsx": [ + 2, + "the action rows (About, Sign out) and the group row that opens its panel; a page row is a link (E31)", + ], "ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"], "ui/SidePanel.tsx": [2, "panel controls"], "ui/tableHeader.tsx": [1, "a column's sort header"], diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index 242582dd..4ae176cf 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -1,8 +1,14 @@ { "intro": "Operational settings stored in the database — these override the file/env defaults and take effect without a redeploy. Leave a field empty (or reset it) to fall back to the default.", "loading": "Loading configuration…", - "groups": { + "sections": { "access": "Access", + "youtube": "YouTube", + "media": "Media", + "system": "System" + }, + "groups": { + "access": "Registration", "email": "Email / SMTP", "youtube": "YouTube API", "google": "Google sign-in", diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json index f847f2bd..c2c0802d 100644 --- a/frontend/src/i18n/locales/en/nav.json +++ b/frontend/src/i18n/locales/en/nav.json @@ -1,5 +1,6 @@ { "primary": "Primary navigation", + "admin": "Administration", "skipToContent": "Skip to content", "role": { "admin": "Admin", @@ -20,6 +21,7 @@ "config": "Config", "users": "Users", "audit": "Audit", + "admin": "Admin", "logs": "Logs", "settings": "Settings" } diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 75cff01b..01f20190 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -1,8 +1,14 @@ { "intro": "Az adatbázisban tárolt működési beállítások — felülírják a fájl/env alapértékeket, és újratelepítés nélkül lépnek életbe. Hagyd üresen (vagy állítsd vissza) a mezőt az alapértékhez való visszatéréshez.", "loading": "Konfiguráció betöltése…", - "groups": { + "sections": { "access": "Hozzáférés", + "youtube": "YouTube", + "media": "Média", + "system": "Rendszer" + }, + "groups": { + "access": "Regisztráció", "email": "E-mail / SMTP", "youtube": "YouTube API", "google": "Google bejelentkezés", diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json index bb5e07fb..767bb022 100644 --- a/frontend/src/i18n/locales/hu/nav.json +++ b/frontend/src/i18n/locales/hu/nav.json @@ -1,5 +1,6 @@ { "primary": "Fő navigáció", + "admin": "Adminisztráció", "skipToContent": "Ugrás a tartalomra", "role": { "admin": "Admin", @@ -20,6 +21,7 @@ "config": "Konfig", "users": "Felhasználók", "audit": "Eseménynapló", + "admin": "Admin", "logs": "Napló", "settings": "Beállítások" } diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 7441b2ce..edeae591 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -64,6 +64,8 @@ export interface ConfigItem { } export interface SystemConfigData { groups: Record; + /** The page's headings, in order, each with its groups in order; every group is in one (E31). */ + sections: { id: string; groups: string[] }[]; secrets_manageable: boolean; } From e4c80fb7bb4c32b046f5057aa7fa6b2ddc1f588b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 00:49:46 +0200 Subject: [PATCH 07/31] fix(ui): a page's sub-tab is remembered per browser tab, not forever Settings, Configuration, Statistics and Users kept the last sub-tab in localStorage, so every fresh tab opened Configuration on "Plex" as if that were its home. useTabPersistedState keeps it in sessionStorage (account-scoped): a reload and a trip to another page keep it, a new tab starts on the first sub-tab. The pre-selects (a Review link, the Google-link return) write the same store; a value an older build left in localStorage is cleared on first read. --- frontend/src/App.tsx | 4 +- frontend/src/components/AdminUsers.tsx | 4 +- frontend/src/components/ConfigPanel.tsx | 4 +- frontend/src/components/SettingsPanel.tsx | 4 +- frontend/src/components/Stats.tsx | 4 +- .../src/components/adminInvites.dom.test.tsx | 2 +- .../adminPendingAccess.dom.test.tsx | 2 +- frontend/src/lib/adminUsersTab.ts | 6 +- frontend/src/lib/storage.ts | 57 +++++++++++++++++++ frontend/src/lib/tabState.dom.test.ts | 45 +++++++++++++++ 10 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/tabState.dom.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index deac0a4c..2b0db8f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,7 +16,7 @@ import { type PanelLayout, } from "./lib/panelLayout"; import { notify, removeByMetaKind } from "./lib/notifications"; -import { LS, setAccountRaw } from "./lib/storage"; +import { LS, setTabRaw } from "./lib/storage"; import { usePref } from "./lib/usePref"; import { useVerifyEntry } from "./lib/useVerifyEntry"; import { useNavigation, useNavigationActions } from "./components/navigationContext"; @@ -249,7 +249,7 @@ export default function App() { if (result === "ok") { notify({ level: "success", message: t("settings.account.googleLink.linked") }); void refetchMe(); - setAccountRaw(LS.settingsTab, "account"); + setTabRaw(LS.settingsTab, "account"); setPage("settings"); } else { const key = result === "conflict" || result === "mismatch" ? result : "error"; diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 1703aba9..7717cf5e 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -41,7 +41,7 @@ import AccountPanel from "./AccountPanel"; import { useConfirm } from "./confirmContext"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; -import { useAccountPersistedState } from "../lib/storage"; +import { useTabPersistedState } from "../lib/storage"; import { PageToolbar } from "./ui/PageShell"; import { useMe } from "../lib/useMe"; import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors"; @@ -69,7 +69,7 @@ const ROW_OPEN = export default function AdminUsers() { const me = useMe(); const { t } = useTranslation(); - const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles"); + const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles"); const panel = useTabPanel(); const tabs = [ { id: "roles", label: t("users.tabs.roles") }, diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index bc43693f..57c8d81d 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -15,7 +15,7 @@ import { Switch } from "./ui/form"; import SegmentedToggle from "./ui/SegmentedToggle"; import FilterSelect from "./ui/FilterSelect"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; -import { LS, useAccountPersistedState } from "../lib/storage"; +import { LS, useTabPersistedState } from "../lib/storage"; import { notifyActionError } from "../lib/actionErrors"; import { Button } from "./ui/Button"; import { CheckRow } from "./ui/NativeControls"; @@ -56,7 +56,7 @@ export default function ConfigPanel() { const [reseedToken, setReseedToken] = useState(0); // One tab per config group (persisted). Called before the loading guard so hook order is // stable; the active id is clamped to a real group at render time once data has loaded. - const [tab, setTab] = useAccountPersistedState(LS.configTab); + const [tab, setTab] = useTabPersistedState(LS.configTab); // A stable signature of the config SHAPE — changes only when keys are added/removed, not when an // unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 4fa8ee34..0feaa259 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -10,7 +10,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, HttpError, type Me } from "../lib/api"; import { displayName, roleKey } from "../lib/api/account"; import { notifyActionError } from "../lib/actionErrors"; -import { LS, useAccountPersistedState } from "../lib/storage"; +import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage"; import Avatar from "./ui/Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; @@ -68,7 +68,7 @@ export default function SettingsPanel() { const prefs = usePrefs(); // "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button). const { openWizard } = useWizard(); - const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance"); + const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance"); const panel = useTabPanel(); // Clamp at render so a stale/removed tab id falls back to Appearance. const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index ad70f22d..e84ab0ff 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -8,7 +8,7 @@ import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/fo import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { useWizard } from "./wizardContext"; -import { LS, useAccountPersistedState } from "../lib/storage"; +import { LS, useTabPersistedState } from "../lib/storage"; import Tooltip from "./ui/Tooltip"; import { LoadingState, StateMessage } from "./ui/QueryState"; import FilterChipGroup from "./ui/FilterChipGroup"; @@ -34,7 +34,7 @@ export default function Stats() { const me = useMe(); const { t } = useTranslation(); const isAdmin = me.role === "admin"; - const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview"); + const [tabRaw, setTab] = useTabPersistedState(LS.statsTab, "overview"); // Clamp at render: only admins may land on the System tab (covers a stale stored value). const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; const panel = useTabPanel(); diff --git a/frontend/src/components/adminInvites.dom.test.tsx b/frontend/src/components/adminInvites.dom.test.tsx index 536b98cb..e7bb319a 100644 --- a/frontend/src/components/adminInvites.dom.test.tsx +++ b/frontend/src/components/adminInvites.dom.test.tsx @@ -57,7 +57,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock })); vi.mock("./ui/Tabs", () => ({ default: () => null })); vi.mock("../lib/storage", async (importOriginal) => ({ ...(await importOriginal()), - useAccountPersistedState: () => ["access", () => {}], + useTabPersistedState: () => ["access", () => {}], })); const listMock = vi.fn(); diff --git a/frontend/src/components/adminPendingAccess.dom.test.tsx b/frontend/src/components/adminPendingAccess.dom.test.tsx index 7a33d17d..2faed812 100644 --- a/frontend/src/components/adminPendingAccess.dom.test.tsx +++ b/frontend/src/components/adminPendingAccess.dom.test.tsx @@ -48,7 +48,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock })); vi.mock("./ui/Tabs", () => ({ default: () => null })); vi.mock("../lib/storage", async (importOriginal) => ({ ...(await importOriginal()), - useAccountPersistedState: () => ["access", () => {}], + useTabPersistedState: () => ["access", () => {}], })); const pendingMock = vi.fn(); diff --git a/frontend/src/lib/adminUsersTab.ts b/frontend/src/lib/adminUsersTab.ts index dff16f7a..1aa799e6 100644 --- a/frontend/src/lib/adminUsersTab.ts +++ b/frontend/src/lib/adminUsersTab.ts @@ -1,8 +1,8 @@ -import { LS, setAccountRaw } from "./storage"; +import { LS, setTabRaw } from "./storage"; // The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to // pre-select that tab before navigating there (a notification's "Review" link uses it — -// useAccountPersistedState reads the key on mount, and the page mounts fresh on navigation). +// useTabPersistedState reads the key on mount, and the page mounts fresh on navigation). // // Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab // WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull @@ -11,5 +11,5 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; const ADMIN_USERS_ACCESS_TAB = "access"; export function focusAccessRequestsTab(): void { - setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); + setTabRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); } diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 264cf7a5..1a2feee7 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -42,6 +42,8 @@ export const LS = { channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize", channelsTable: "siftlode.channelsTable", channelDiscoveryTable: "siftlode.channelDiscoveryTable", + // A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and + // statsTab below. settingsTab: "siftlode.settingsTab", configTab: "siftlode.configTab", auditTable: "siftlode.auditTable", @@ -174,6 +176,61 @@ export function useAccountPersistedState( return [value, set]; } +// --- a page's sub-tab, per browser tab --------------------------------------------------------- + +// Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser +// tab only — sessionStorage, account-scoped: it survives a reload and a trip to another page, and a new +// tab opens the page on its first sub-tab (the owner, E31: a position remembered forever in +// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home). + +/** The sub-tab stored for this browser tab, or null. */ +function getTabRaw(base: string): string | null { + const k = accountKey(base); + try { + return k ? sessionStorage.getItem(k) : null; + } catch { + return null; + } +} + +/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */ +export function setTabRaw(base: string, value: string): void { + const k = accountKey(base); + if (!k) return; + try { + sessionStorage.setItem(k, value); + } catch { + /* ignore */ + } +} + +/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */ +export function useTabPersistedState( + base: string, + fallback = "", +): [string, (value: string) => void] { + const [value, setValue] = useState(() => { + // Before E31 the position lived in localStorage; a value left there would never be read again. + const old = accountKey(base); + if (old) { + try { + localStorage.removeItem(old); + } catch { + /* ignore */ + } + } + return getTabRaw(base) ?? fallback; + }); + const set = useCallback( + (next: string) => { + setValue(next); + setTabRaw(base, next); + }, + [base], + ); + return [value, set]; +} + /** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall * back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of * related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */ diff --git a/frontend/src/lib/tabState.dom.test.ts b/frontend/src/lib/tabState.dom.test.ts new file mode 100644 index 00000000..4dceea4d --- /dev/null +++ b/frontend/src/lib/tabState.dom.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +// +// E31 (the owner): a page's sub-tab was remembered forever in localStorage, so every fresh browser tab +// opened Configuration on "Plex" as if that were its home. The position is now kept per browser tab +// (sessionStorage): it survives a reload and a trip to another page, and a new tab starts on the first. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { ACTIVE_ACCOUNT_KEY } from "./activeAccount"; +import { setTabRaw, useTabPersistedState } from "./storage"; + +beforeEach(() => { + sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "1"); +}); +afterEach(() => { + sessionStorage.clear(); + localStorage.clear(); +}); + +describe("a page's sub-tab, per browser tab", () => { + it("starts on the fallback, then keeps the picked tab for this browser tab only", () => { + const first = renderHook(() => useTabPersistedState("siftlode.configTab", "access")); + expect(first.result.current[0]).toBe("access"); + act(() => first.result.current[1]("plex")); + expect(sessionStorage.getItem("siftlode.configTab.1")).toBe("plex"); + expect(localStorage.getItem("siftlode.configTab.1")).toBeNull(); + // The page mounts again in this tab (a reload, or back from another page): it is where it was. + first.unmount(); + expect( + renderHook(() => useTabPersistedState("siftlode.configTab", "access")).result.current[0], + ).toBe("plex"); + }); + + it("ignores a position left in localStorage by an older build, and clears it", () => { + localStorage.setItem("siftlode.configTab.1", "plex"); + const { result } = renderHook(() => useTabPersistedState("siftlode.configTab", "access")); + expect(result.current[0]).toBe("access"); + expect(localStorage.getItem("siftlode.configTab.1")).toBeNull(); + }); + + it("a pre-selected tab is the one the page opens on", () => { + setTabRaw("siftlode.adminUsersTab", "access"); + const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles")); + expect(result.current[0]).toBe("access"); + }); +}); From 98d0ca6eebc3e9526d2c8a5780123d4bcf20d837 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 02:13:17 +0200 Subject: [PATCH 08/31] fix(i18n): every counted string is a plural pair, so one reads singular "1 seasons", "Synced 1 subscriptions", "1 video(s) were skipped": 21 English strings carried {{count}} in one plain key and read plural at one. Each is a _one/_other pair now (Hungarian keeps one form for both), and so is every other counted string, so the next wording change is a plural too. A contract test holds the rule for every locale file. --- frontend/src/i18n/locales/en/audit.json | 3 +- frontend/src/i18n/locales/en/channels.json | 9 +++-- frontend/src/i18n/locales/en/common.json | 6 ++-- frontend/src/i18n/locales/en/editor.json | 3 +- frontend/src/i18n/locales/en/header.json | 9 +++-- frontend/src/i18n/locales/en/inbox.json | 3 +- frontend/src/i18n/locales/en/onboarding.json | 3 +- frontend/src/i18n/locales/en/palette.json | 4 +-- frontend/src/i18n/locales/en/playlists.json | 12 ++++--- frontend/src/i18n/locales/en/plex.json | 24 ++++++++----- frontend/src/i18n/locales/en/scheduler.json | 6 ++-- frontend/src/i18n/locales/en/settings.json | 9 +++-- frontend/src/i18n/locales/en/sidebar.json | 3 +- frontend/src/i18n/locales/en/stats.json | 9 +++-- frontend/src/i18n/locales/en/tagManager.json | 3 +- frontend/src/i18n/locales/hu/audit.json | 3 +- frontend/src/i18n/locales/hu/channels.json | 9 +++-- frontend/src/i18n/locales/hu/common.json | 6 ++-- frontend/src/i18n/locales/hu/editor.json | 3 +- frontend/src/i18n/locales/hu/header.json | 9 +++-- frontend/src/i18n/locales/hu/inbox.json | 3 +- frontend/src/i18n/locales/hu/onboarding.json | 3 +- frontend/src/i18n/locales/hu/palette.json | 4 +-- frontend/src/i18n/locales/hu/playlists.json | 12 ++++--- frontend/src/i18n/locales/hu/plex.json | 24 ++++++++----- frontend/src/i18n/locales/hu/scheduler.json | 6 ++-- frontend/src/i18n/locales/hu/settings.json | 9 +++-- frontend/src/i18n/locales/hu/sidebar.json | 3 +- frontend/src/i18n/locales/hu/stats.json | 9 +++-- frontend/src/i18n/locales/hu/tagManager.json | 3 +- frontend/src/i18n/plurals.test.ts | 37 ++++++++++++++++++++ 31 files changed, 177 insertions(+), 72 deletions(-) diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index f4513bd8..454f58ff 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -6,7 +6,8 @@ "clear": "Clear audit log", "clearTitle": "Clear the audit log?", "clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)", - "cleared": "Cleared {{count}} audit entries", + "cleared_one": "Cleared {{count}} audit entry", + "cleared_other": "Cleared {{count}} audit entries", "clearFailed": "Couldn't clear the audit log", "truncated": "Showing the {{returned}} most recent of {{total}} entries.", "on": "On", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 5aee7e43..3d9b956b 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -94,7 +94,8 @@ "fullHistory": "Full history", "fullHistoryHint": "Channels whose entire back-catalog is fetched, out of the ones you've requested full history for.", "left": "left", - "leftHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.", + "leftHint_one": "Rough estimate to finish full-history backfill of {{count}} pending channel, based on the shared daily quota.", + "leftHint_other": "Rough estimate to finish full-history backfill of {{count}} pending channels, based on the shared daily quota.", "myVideos": "My videos", "myVideosHint": "Total videos available across your channels.", "quotaLeft": "Quota left", @@ -125,11 +126,13 @@ "noTagsYet": "No tags yet — make one with a row’s tag “+”" }, "notify": { - "synced": "Synced {{count}} subscriptions", + "synced_one": "Synced {{count}} subscription", + "synced_other": "Synced {{count}} subscriptions", "syncFailed": "Couldn't sync subscriptions", "unsubscribed": "Unsubscribed from {{name}} on YouTube", "unsubscribeFailed": "Couldn't unsubscribe", - "fullHistoryRequested": "Full history requested for {{count}} channels", + "fullHistoryRequested_one": "Full history requested for {{count}} channel", + "fullHistoryRequested_other": "Full history requested for {{count}} channels", "fullHistoryFailed": "Couldn't request full history", "needYouTube": "Connect your YouTube account to do this", "connect": "Connect", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index ea7acbc9..c1ca9c55 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -14,7 +14,8 @@ "itemCount_one": "{{formattedCount}} item", "itemCount_other": "{{formattedCount}} items", "accessRequestsTitle": "Access requests", - "accessRequestsMessage": "{{count}} pending — review them on the Users page.", + "accessRequestsMessage_one": "{{count}} pending — review it on the Users page.", + "accessRequestsMessage_other": "{{count}} pending — review them on the Users page.", "accessRequestsReview": "Review", "demoTitle": "Demo account", "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.", @@ -33,7 +34,8 @@ "lower": "Lower priority", "edit": "Click to type a priority (negatives allowed)" }, - "moreRefine": "{{count}} more — refine with search", + "moreRefine_one": "{{count}} more — refine with search", + "moreRefine_other": "{{count}} more — refine with search", "matchMode": "Match", "match": { "any": "Any", diff --git a/frontend/src/i18n/locales/en/editor.json b/frontend/src/i18n/locales/en/editor.json index 45fd89f9..8ded648f 100644 --- a/frontend/src/i18n/locales/en/editor.json +++ b/frontend/src/i18n/locales/en/editor.json @@ -18,7 +18,8 @@ "willReencode": "Will re-encode", "willCopy": "Instant (stream copy)", "create": "Create clip", - "createN": "Create {{count}} clips", + "createN_one": "Create {{count}} clip", + "createN_other": "Create {{count}} clips", "created_one": "{{count}} clip added to your downloads", "created_other": "{{count}} clips added to your downloads", "failed": "Couldn’t create the clip", diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 5b841765..c03b081f 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -40,12 +40,15 @@ "total": "total", "countTooltip": "Your subscriptions vs. the whole shared catalog.", "paused": "paused", - "syncing": "{{count}} syncing", + "syncing_one": "{{count}} syncing", + "syncing_other": "{{count}} syncing", "backfillingHistory": "fetching history", "working": "syncing…", - "recentQueued": "{{count}} queued", + "recentQueued_one": "{{count}} queued", + "recentQueued_other": "{{count}} queued", "allSynced": "all synced", - "withoutFullHistory": "{{count}} without full history", + "withoutFullHistory_one": "{{count}} without full history", + "withoutFullHistory_other": "{{count}} without full history", "fullHistoryTooltip": "Recent uploads only — click to fetch the rest.", "pause": "Pause sync", "resume": "Resume sync" diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index edff4fac..c4eb9bf9 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -11,7 +11,8 @@ "menu": "Notifications actions", "sectionSystem": "System", "sectionActivity": "Activity", - "andMore": "and {{count}} more", + "andMore_one": "and {{count}} more", + "andMore_other": "and {{count}} more", "openScheduler": "Open Scheduler", "openDownloads": "Open Downloads", "revertFailed": "Couldn't undo that — please try again", diff --git a/frontend/src/i18n/locales/en/onboarding.json b/frontend/src/i18n/locales/en/onboarding.json index 5259b23e..48ca1ee3 100644 --- a/frontend/src/i18n/locales/en/onboarding.json +++ b/frontend/src/i18n/locales/en/onboarding.json @@ -15,7 +15,8 @@ "title": "You're connected", "importFailed": "Your YouTube account is connected, but we couldn't import your subscriptions — you can retry from Settings → Sync. ", "feedReady": "Your Feed is ready{{channels}}. Optionally, let Siftlode ", - "feedReadyChannels": " ({{count}} channels)", + "feedReadyChannels_one": " ({{count}} channel)", + "feedReadyChannels_other": " ({{count}} channels)", "rationale": "<0>unsubscribe from channels on YouTube for you — this needs an extra write permission. Skip it to stay read-only; you can always enable it later in Settings.", "retryImport": "Retry import", "enableUnsubscribe": "Enable unsubscribe", diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json index 930393d2..0c4403fb 100644 --- a/frontend/src/i18n/locales/en/palette.json +++ b/frontend/src/i18n/locales/en/palette.json @@ -2,10 +2,10 @@ "title": "Jump to…", "placeholder": "Search modules, channels, playlists…", "empty": "Nothing matches.", - "results": "{{count}} result", + "results_one": "{{count}} result", "results_other": "{{count}} results", "theme": "Theme: {{name}}", - "videoCount": "{{count}} video", + "videoCount_one": "{{count}} video", "videoCount_other": "{{count}} videos", "groups": { "modules": "Modules", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index e6adfa49..10cd22da 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -4,7 +4,8 @@ "listGroup": "Your playlists", "watchLater": "Watch later", "syncYoutube": "Sync from YouTube", - "syncedToast": "Synced {{count}} playlists from YouTube", + "syncedToast_one": "Synced {{count}} playlist from YouTube", + "syncedToast_other": "Synced {{count}} playlists from YouTube", "syncFailed": "Couldn't sync from YouTube", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", @@ -34,7 +35,8 @@ "revertFailed": "Couldn't sync from YouTube", "pushTitle": "Sync to YouTube", "pushConfirm": "Sync", - "pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)", + "pushPlanCreate_one": "Create a new YouTube playlist with {{count}} video? (~{{units}} quota units; {{left}} left today.)", + "pushPlanCreate_other": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)", "mergeAddLocal_one": "{{count}} video added to Siftlode from YouTube.", "mergeAddLocal_other": "{{count}} videos added to Siftlode from YouTube.", "mergeRemoveLocal_one": "{{count}} video removed from Siftlode.", @@ -53,7 +55,8 @@ "pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.", "pushUpToDate": "“{{name}}” is already in sync with YouTube", "pushDone": "“{{name}}” synced to YouTube", - "pushPartial": "Synced, but {{count}} video(s) were skipped", + "pushPartial_one": "Synced, but {{count}} video was skipped", + "pushPartial_other": "Synced, but {{count}} videos were skipped", "pushFailed": "Couldn't sync to YouTube", "deleteHereOnly": "Delete here only", "deleteYtFailed": "Couldn't delete on YouTube; deleted here only", @@ -103,7 +106,8 @@ "dragBlockedFilter": "Reordering by hand is off while a filter is applied.", "clearView": "Reset view", "saveOrder": "Save this order", - "filteredCount": "{{count}} shown", + "filteredCount_one": "{{count}} shown", + "filteredCount_other": "{{count}} shown", "noneMatchFilter": "No videos match the filter.", "loadFailed": "Couldn't load this playlist", "reorderFailed": "Couldn't save the new order", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 7b0781a8..6df97c52 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -128,7 +128,8 @@ "seasonTitle": "Season {{n}}", "specials": "Specials", "watchedFailed": "Couldn't update the watched state — please try again", - "seasons": "{{count}} seasons", + "seasons_one": "{{count}} season", + "seasons_other": "{{count}} seasons", "unified": { "titles": "Titles", "episodes": "Episodes" @@ -136,7 +137,8 @@ "series": { "seasons": "Seasons", "backToShow": "Back to show", - "episodeCount": "{{count}} episodes", + "episodeCount_one": "{{count}} episode", + "episodeCount_other": "{{count}} episodes", "showFromStart": "Show from the start", "seasonFromStart": "Season from the start", "related": "Related shows", @@ -154,11 +156,13 @@ "create": "Create", "search": "Filter collections…", "none": "No editable collections yet. Create one above.", - "count": "{{count}} items", + "count_one": "{{count}} item", + "count_other": "{{count}} items", "delete": "Delete", "deleteConfirm": "Delete the collection “{{title}}” from Plex? This affects all users.", "note": "Changes are written to Plex and shared across all clients and users.", - "others": "Manage {{count}} existing collections", + "others_one": "Manage {{count}} existing collection", + "others_other": "Manage {{count}} existing collections", "makeEditable": "Manage" }, "player": { @@ -298,7 +302,8 @@ "newPlaceholder": "New playlist name…", "create": "Create", "none": "No playlists yet. Create one above.", - "count": "{{count}} items", + "count_one": "{{count}} item", + "count_other": "{{count}} items", "groupProgress": "{{in}} / {{size}}", "addFailed": "Couldn't add it to the playlist", "removeFailed": "Couldn't remove it from the playlist", @@ -319,7 +324,8 @@ "layoutTree": "Tree view", "removeShow": "Remove whole show", "removeSeason": "Remove whole season", - "episodes": "{{count}} episodes", + "episodes_one": "{{count}} episode", + "episodes_other": "{{count}} episodes", "seasonUnknown": "Episodes", "addShow": "Add whole show to a playlist", "addSeason": "Add season to a playlist", @@ -345,8 +351,10 @@ "person": { "credits": "{{movies}} movies · {{shows}} shows", "filterBy": "Filter library by {{name}}", - "movies": "Movies ({{count}})", - "shows": "Shows ({{count}})", + "movies_one": "Movies ({{count}})", + "movies_other": "Movies ({{count}})", + "shows_one": "Shows ({{count}})", + "shows_other": "Shows ({{count}})", "empty": "No titles found for this person.", "born": "Born", "died": "Died", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index c6b82cc5..1f81ed2a 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -14,7 +14,8 @@ "jobsTitle": "Jobs", "queueTitle": "Work queued", "quotaTitle": "Shared API quota", - "everyMin": "every {{count}} min", + "everyMin_one": "every {{count}} min", + "everyMin_other": "every {{count}} min", "runningNow": "Running now…", "lastRun": "Last run {{time}}", "neverRun": "Hasn't run yet this session", @@ -115,7 +116,8 @@ "purgeDiscovery": "Purge discovery", "purgeDiscoveryHint": "Delete all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.", "purgeConfirmTitle": "Purge discovery content?", - "purgedDiscovery": "Deleted {{count}} discovery items", + "purgedDiscovery_one": "Deleted {{count}} discovery item", + "purgedDiscovery_other": "Deleted {{count}} discovery items", "result": { "new": "new videos", "failed": "failed", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 49619177..89a5ec43 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -147,7 +147,8 @@ "resetHint": "Wipe the demo account's playlists, hidden/watched/saved videos and settings, then re-seed a few sample playlists.", "resetTitle": "Reset demo state?", "resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.", - "resetDone": "Demo reset — {{count}} sample playlist(s) seeded", + "resetDone_one": "Demo reset — {{count}} sample playlist seeded", + "resetDone_other": "Demo reset — {{count}} sample playlists seeded", "resetFailed": "Couldn't reset the demo account", "removed": "Removed from the demo whitelist", "removeTitle": "Remove demo access?", @@ -158,7 +159,8 @@ "noPending": "No pending requests.", "addPlaceholder": "Add an email directly…", "add": "Add", - "decided": "{{count}} decided", + "decided_one": "{{count}} decided", + "decided_other": "{{count}} decided", "approved": "Approved {{email}} — they can sign in now", "approveFailed": "Couldn't approve the request", "denyFailed": "Couldn't deny the request", @@ -243,7 +245,8 @@ }, "count_one": "{{count}} event · newest first", "count_other": "{{count}} events · newest first", - "capped": "showing the newest {{shown}} of {{count}}", + "capped_one": "showing the newest {{shown}} of {{count}}", + "capped_other": "showing the newest {{shown}} of {{count}}", "copy": "Copy", "copied_one": "Copied {{count}} event", "copied_other": "Copied {{count}} events", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index b25e3437..c5508f32 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -1,6 +1,7 @@ { "filters": "Filters", - "activeCount": "{{count}} active", + "activeCount_one": "{{count}} active", + "activeCount_other": "{{count}} active", "clearAll": "Clear all", "resetDefaults": "Reset to defaults", "customize": "Customize sidebar", diff --git a/frontend/src/i18n/locales/en/stats.json b/frontend/src/i18n/locales/en/stats.json index 0f660043..ae3935e9 100644 --- a/frontend/src/i18n/locales/en/stats.json +++ b/frontend/src/i18n/locales/en/stats.json @@ -24,7 +24,8 @@ "fullHistory": "Full history", "fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).", "fullHistoryEta": "Full history ETA", - "fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.", + "fullHistoryEtaHint_one": "Rough estimate to finish full-history backfill of {{count}} pending channel, based on the shared daily quota.", + "fullHistoryEtaHint_other": "Rough estimate to finish full-history backfill of {{count}} pending channels, based on the shared daily quota.", "myVideos": "My videos", "myVideosHint": "Total videos available to you across your subscribed channels.", "quotaLeft": "Quota left today", @@ -43,9 +44,11 @@ "syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.", "backfillEverything": "Backfill everything", "backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.", - "synced": "Synced {{count}} subscriptions", + "synced_one": "Synced {{count}} subscription", + "synced_other": "Synced {{count}} subscriptions", "syncFailed": "Couldn't sync subscriptions", - "fullHistoryRequested": "Full history requested for {{count}} channels", + "fullHistoryRequested_one": "Full history requested for {{count}} channel", + "fullHistoryRequested_other": "Full history requested for {{count}} channels", "fullHistoryFailed": "Couldn't request full history", "admin": "Admin", "adminPauseHint": "Pause or resume the background sync for the whole app (all users).", diff --git a/frontend/src/i18n/locales/en/tagManager.json b/frontend/src/i18n/locales/en/tagManager.json index 10e50c74..ce8ebf90 100644 --- a/frontend/src/i18n/locales/en/tagManager.json +++ b/frontend/src/i18n/locales/en/tagManager.json @@ -1,6 +1,7 @@ { "title": "Manage tags", - "channels": "{{count}} ch.", + "channels_one": "{{count}} ch.", + "channels_other": "{{count}} ch.", "onChannels": "Tagged channels", "delete": "Delete", "deleted": "Tag “{{name}}” deleted", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 294ce05e..60b56a83 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -6,7 +6,8 @@ "clear": "Eseménynapló ürítése", "clearTitle": "Üríted az eseménynaplót?", "clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)", - "cleared": "{{count}} naplóbejegyzés törölve", + "cleared_one": "{{count}} naplóbejegyzés törölve", + "cleared_other": "{{count}} naplóbejegyzés törölve", "clearFailed": "Nem sikerült üríteni az eseménynaplót", "truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.", "on": "Be", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 3c6e61bb..f73a6dd6 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -94,7 +94,8 @@ "fullHistory": "Teljes előzmény", "fullHistoryHint": "Csatornák, amelyek teljes archívuma le van töltve, azok közül, amelyekhez teljes előzményt kértél.", "left": "hátra", - "leftHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzására, a megosztott napi kvóta alapján.", + "leftHint_one": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzására, a megosztott napi kvóta alapján.", + "leftHint_other": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzására, a megosztott napi kvóta alapján.", "myVideos": "Videóim", "myVideosHint": "A csatornáidon elérhető videók teljes száma.", "quotaLeft": "Maradék kvóta", @@ -125,11 +126,13 @@ "noTagsYet": "Még nincs címkéd — egy sor címke-„+” gombjával hozhatsz létre" }, "notify": { - "synced": "{{count}} feliratkozás szinkronizálva", + "synced_one": "{{count}} feliratkozás szinkronizálva", + "synced_other": "{{count}} feliratkozás szinkronizálva", "syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat", "unsubscribed": "Leiratkozva a(z) {{name}} csatornáról a YouTube-on", "unsubscribeFailed": "Nem sikerült leiratkozni", - "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", + "fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához", + "fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához", "fullHistoryFailed": "Nem sikerült teljes előzményt kérni", "needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod", "connect": "Csatlakoztatás", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index b91fa02c..93c2ef68 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -14,7 +14,8 @@ "itemCount_one": "{{formattedCount}} elem", "itemCount_other": "{{formattedCount}} elem", "accessRequestsTitle": "Hozzáférési kérelmek", - "accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.", + "accessRequestsMessage_one": "{{count}} függőben — nézd át a Felhasználók oldalon.", + "accessRequestsMessage_other": "{{count}} függőben — nézd át a Felhasználók oldalon.", "accessRequestsReview": "Áttekintés", "demoTitle": "Demó fiók", "demoSharedNotice": "Egy közös demó fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demó látogatók egyszerre módosíthatják.", @@ -33,7 +34,8 @@ "lower": "Prioritás csökkentése", "edit": "Kattints a prioritás beírásához (negatív is lehet)" }, - "moreRefine": "még {{count}} — finomíts kereséssel", + "moreRefine_one": "még {{count}} — finomíts kereséssel", + "moreRefine_other": "még {{count}} — finomíts kereséssel", "matchMode": "Egyezés", "match": { "any": "Bármelyik", diff --git a/frontend/src/i18n/locales/hu/editor.json b/frontend/src/i18n/locales/hu/editor.json index 5203cde7..d8bf9275 100644 --- a/frontend/src/i18n/locales/hu/editor.json +++ b/frontend/src/i18n/locales/hu/editor.json @@ -18,7 +18,8 @@ "willReencode": "Újrakódolás lesz", "willCopy": "Azonnali (másolás)", "create": "Klip létrehozása", - "createN": "{{count}} klip létrehozása", + "createN_one": "{{count}} klip létrehozása", + "createN_other": "{{count}} klip létrehozása", "created_one": "{{count}} klip a letöltésekhez adva", "created_other": "{{count}} klip a letöltésekhez adva", "failed": "Nem sikerült létrehozni a klipet", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 9049a448..ed782b2e 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -40,12 +40,15 @@ "total": "összes", "countTooltip": "A feliratkozásaid a teljes megosztott katalógushoz képest.", "paused": "szüneteltetve", - "syncing": "{{count}} szinkronizálás alatt", + "syncing_one": "{{count}} szinkronizálás alatt", + "syncing_other": "{{count}} szinkronizálás alatt", "backfillingHistory": "előzmény behúzása", "working": "szinkronizálás…", - "recentQueued": "{{count}} sorban", + "recentQueued_one": "{{count}} sorban", + "recentQueued_other": "{{count}} sorban", "allSynced": "minden szinkronban", - "withoutFullHistory": "{{count}} teljes előzmény nélkül", + "withoutFullHistory_one": "{{count}} teljes előzmény nélkül", + "withoutFullHistory_other": "{{count}} teljes előzmény nélkül", "fullHistoryTooltip": "Csak a legutóbbi feltöltések — kattints a többiért.", "pause": "Szinkron szüneteltetése", "resume": "Szinkron folytatása" diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 477214bf..8f16fdba 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -11,7 +11,8 @@ "menu": "Értesítések műveletei", "sectionSystem": "Rendszer", "sectionActivity": "Tevékenység", - "andMore": "és még {{count}}", + "andMore_one": "és még {{count}}", + "andMore_other": "és még {{count}}", "openScheduler": "Ütemező megnyitása", "openDownloads": "Letöltések megnyitása", "revertFailed": "Nem sikerült visszavonni — próbáld újra", diff --git a/frontend/src/i18n/locales/hu/onboarding.json b/frontend/src/i18n/locales/hu/onboarding.json index dd4fcdc8..38b122a7 100644 --- a/frontend/src/i18n/locales/hu/onboarding.json +++ b/frontend/src/i18n/locales/hu/onboarding.json @@ -15,7 +15,8 @@ "title": "Csatlakoztatva", "importFailed": "A YouTube-fiókod csatlakoztatva, de nem sikerült importálni a feliratkozásaidat — újrapróbálhatod a Beállítások → Szinkronizálás menüben. ", "feedReady": "A Feeded készen áll{{channels}}. Választhatóan engedélyezheted, hogy a Siftlode ", - "feedReadyChannels": " ({{count}} csatorna)", + "feedReadyChannels_one": " ({{count}} csatorna)", + "feedReadyChannels_other": " ({{count}} csatorna)", "rationale": "<0>leiratkozzon helyetted a YouTube-csatornákról — ehhez egy plusz írási engedély szükséges. Hagyd ki, ha csak olvasható módban szeretnél maradni; ezt később bármikor engedélyezheted a Beállításokban.", "retryImport": "Importálás újra", "enableUnsubscribe": "Leiratkozás engedélyezése", diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json index 39ee5fd9..551ca07b 100644 --- a/frontend/src/i18n/locales/hu/palette.json +++ b/frontend/src/i18n/locales/hu/palette.json @@ -2,10 +2,10 @@ "title": "Ugrás…", "placeholder": "Modul, csatorna, lista keresése…", "empty": "Nincs találat.", - "results": "{{count}} találat", + "results_one": "{{count}} találat", "results_other": "{{count}} találat", "theme": "Téma: {{name}}", - "videoCount": "{{count}} videó", + "videoCount_one": "{{count}} videó", "videoCount_other": "{{count}} videó", "groups": { "modules": "Modulok", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index ce7cec69..45e57c91 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -4,7 +4,8 @@ "listGroup": "Listáid", "watchLater": "Megnézem később", "syncYoutube": "Szinkronizálás a YouTube-ról", - "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", + "syncedToast_one": "{{count}} lista szinkronizálva a YouTube-ról", + "syncedToast_other": "{{count}} lista szinkronizálva a YouTube-ról", "syncFailed": "Nem sikerült szinkronizálni a YouTube-ról", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", @@ -34,7 +35,8 @@ "revertFailed": "Nem sikerült szinkronizálni a YouTube-ról", "pushTitle": "Szinkronizálás a YouTube-ra", "pushConfirm": "Szinkronizálás", - "pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)", + "pushPlanCreate_one": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)", + "pushPlanCreate_other": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)", "mergeAddLocal_one": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.", "mergeAddLocal_other": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.", "mergeRemoveLocal_one": "{{count}} videó törlődik a Siftlode-listádból.", @@ -53,7 +55,8 @@ "pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.", "pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal", "pushDone": "„{{name}}” szinkronizálva a YouTube-ra", - "pushPartial": "Szinkronizálva, de {{count}} videó kimaradt", + "pushPartial_one": "Szinkronizálva, de {{count}} videó kimaradt", + "pushPartial_other": "Szinkronizálva, de {{count}} videó kimaradt", "pushFailed": "Nem sikerült szinkronizálni a YouTube-ra", "deleteHereOnly": "Törlés csak itt", "deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt törölve", @@ -103,7 +106,8 @@ "dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.", "clearView": "Nézet visszaállítása", "saveOrder": "Sorrend mentése", - "filteredCount": "{{count}} látszik", + "filteredCount_one": "{{count}} látszik", + "filteredCount_other": "{{count}} látszik", "noneMatchFilter": "Egy videó sem felel meg a szűrőnek.", "loadFailed": "Nem sikerült betölteni ezt a listát", "reorderFailed": "Nem sikerült elmenteni az új sorrendet", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 844c82d3..20165d44 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -128,7 +128,8 @@ "seasonTitle": "{{n}}. évad", "specials": "Különkiadások", "watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra", - "seasons": "{{count}} évad", + "seasons_one": "{{count}} évad", + "seasons_other": "{{count}} évad", "unified": { "titles": "Címek", "episodes": "Epizódok" @@ -136,7 +137,8 @@ "series": { "seasons": "Évadok", "backToShow": "Vissza a sorozathoz", - "episodeCount": "{{count}} rész", + "episodeCount_one": "{{count}} rész", + "episodeCount_other": "{{count}} rész", "showFromStart": "Sorozat elejéről", "seasonFromStart": "Évad elejéről", "related": "Kapcsolódó sorozatok", @@ -154,11 +156,13 @@ "create": "Létrehozás", "search": "Kollekciók szűrése…", "none": "Még nincs szerkeszthető kollekció. Hozz létre egyet fent.", - "count": "{{count}} elem", + "count_one": "{{count}} elem", + "count_other": "{{count}} elem", "delete": "Törlés", "deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót a Plexből? Ez minden felhasználót érint.", "note": "A változtatások a Plexbe íródnak, és minden kliensen/felhasználónál megjelennek.", - "others": "{{count}} meglévő kollekció kezelése", + "others_one": "{{count}} meglévő kollekció kezelése", + "others_other": "{{count}} meglévő kollekció kezelése", "makeEditable": "Kezelem" }, "player": { @@ -298,7 +302,8 @@ "newPlaceholder": "Új lista neve…", "create": "Létrehozás", "none": "Még nincs lista. Hozz létre egyet fent.", - "count": "{{count}} elem", + "count_one": "{{count}} elem", + "count_other": "{{count}} elem", "groupProgress": "{{in}} / {{size}}", "addFailed": "Nem sikerült hozzáadni a lejátszási listához", "removeFailed": "Nem sikerült eltávolítani a lejátszási listáról", @@ -319,7 +324,8 @@ "layoutTree": "Fa nézet", "removeShow": "Egész sorozat eltávolítása", "removeSeason": "Egész évad eltávolítása", - "episodes": "{{count}} epizód", + "episodes_one": "{{count}} epizód", + "episodes_other": "{{count}} epizód", "seasonUnknown": "Epizódok", "addShow": "Egész sorozat listához adása", "addSeason": "Évad listához adása", @@ -345,8 +351,10 @@ "person": { "credits": "{{movies}} film · {{shows}} sorozat", "filterBy": "Szűrés erre: {{name}}", - "movies": "Filmek ({{count}})", - "shows": "Sorozatok ({{count}})", + "movies_one": "Filmek ({{count}})", + "movies_other": "Filmek ({{count}})", + "shows_one": "Sorozatok ({{count}})", + "shows_other": "Sorozatok ({{count}})", "empty": "Nincs találat ehhez a személyhez.", "born": "Született", "died": "Elhunyt", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index f1011665..c6f79124 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -14,7 +14,8 @@ "jobsTitle": "Feladatok", "queueTitle": "Sorban álló munka", "quotaTitle": "Közös API-kvóta", - "everyMin": "{{count}} percenként", + "everyMin_one": "{{count}} percenként", + "everyMin_other": "{{count}} percenként", "runningNow": "Most fut…", "lastRun": "Utolsó futás {{time}}", "neverRun": "Ebben a munkamenetben még nem futott", @@ -115,7 +116,8 @@ "purgeDiscovery": "Felfedezés ürítése", "purgeDiscoveryHint": "Most törli az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.", "purgeConfirmTitle": "Felfedezés-tartalom törlése?", - "purgedDiscovery": "{{count}} felfedezés-elem törölve", + "purgedDiscovery_one": "{{count}} felfedezés-elem törölve", + "purgedDiscovery_other": "{{count}} felfedezés-elem törölve", "result": { "new": "új videó", "failed": "sikertelen", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 5585ae23..c641fba3 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -147,7 +147,8 @@ "resetHint": "Törli a demó fiók lejátszási listáit, elrejtett/megnézett/mentett videóit és beállításait, majd újra létrehoz pár minta lejátszási listát.", "resetTitle": "Visszaállítod a demó állapotot?", "resetConfirm": "Ez törli mindazt, amit a demó fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.", - "resetDone": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva", + "resetDone_one": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva", + "resetDone_other": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva", "resetFailed": "Nem sikerült visszaállítani a demó fiókot", "removed": "Eltávolítva a demó fehérlistáról", "removeTitle": "Visszavonod a demó hozzáférést?", @@ -158,7 +159,8 @@ "noPending": "Nincs függőben lévő kérelem.", "addPlaceholder": "E-mail közvetlen hozzáadása…", "add": "Hozzáadás", - "decided": "{{count}} eldöntve", + "decided_one": "{{count}} eldöntve", + "decided_other": "{{count}} eldöntve", "approved": "Jóváhagyva: {{email}} — most már be tud jelentkezni", "approveFailed": "Nem sikerült jóváhagyni a kérelmet", "denyFailed": "Nem sikerült elutasítani a kérelmet", @@ -243,7 +245,8 @@ }, "count_one": "{{count}} esemény · a legújabb felül", "count_other": "{{count}} esemény · a legújabb felül", - "capped": "a legújabb {{shown}} látszik a(z) {{count}} közül", + "capped_one": "a legújabb {{shown}} látszik a(z) {{count}} közül", + "capped_other": "a legújabb {{shown}} látszik a(z) {{count}} közül", "copy": "Másolás", "copied_one": "Kimásolva: {{count}} esemény", "copied_other": "Kimásolva: {{count}} esemény", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 70d70934..87626359 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -1,6 +1,7 @@ { "filters": "Szűrők", - "activeCount": "{{count}} aktív", + "activeCount_one": "{{count}} aktív", + "activeCount_other": "{{count}} aktív", "clearAll": "Összes törlése", "resetDefaults": "Visszaállítás alapértelmezettre", "customize": "Oldalsáv testreszabása", diff --git a/frontend/src/i18n/locales/hu/stats.json b/frontend/src/i18n/locales/hu/stats.json index 3a325ebf..eee941bf 100644 --- a/frontend/src/i18n/locales/hu/stats.json +++ b/frontend/src/i18n/locales/hu/stats.json @@ -24,7 +24,8 @@ "fullHistory": "Teljes előzmény", "fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma be van húzva, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).", "fullHistoryEta": "Teljes előzmény becsült ideje", - "fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzásához, a megosztott napi kvóta alapján.", + "fullHistoryEtaHint_one": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzásához, a megosztott napi kvóta alapján.", + "fullHistoryEtaHint_other": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzásához, a megosztott napi kvóta alapján.", "myVideos": "Saját videók", "myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.", "quotaLeft": "Ma maradt kvóta", @@ -43,9 +44,11 @@ "syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.", "backfillEverything": "Minden videó behúzása", "backfillEverythingHint": "A teljes archívum behúzásának kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.", - "synced": "{{count}} feliratkozás szinkronizálva", + "synced_one": "{{count}} feliratkozás szinkronizálva", + "synced_other": "{{count}} feliratkozás szinkronizálva", "syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat", - "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", + "fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához", + "fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához", "fullHistoryFailed": "Nem sikerült teljes előzményt kérni", "admin": "Adminisztrátor", "adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).", diff --git a/frontend/src/i18n/locales/hu/tagManager.json b/frontend/src/i18n/locales/hu/tagManager.json index 76a6d766..c962f5a6 100644 --- a/frontend/src/i18n/locales/hu/tagManager.json +++ b/frontend/src/i18n/locales/hu/tagManager.json @@ -1,6 +1,7 @@ { "title": "Címkék kezelése", - "channels": "{{count}} csat.", + "channels_one": "{{count}} csat.", + "channels_other": "{{count}} csat.", "onChannels": "Címkézett csatornák", "delete": "Törlés", "deleted": "„{{name}}” címke törölve", diff --git a/frontend/src/i18n/plurals.test.ts b/frontend/src/i18n/plurals.test.ts index 24c3adc2..5b858929 100644 --- a/frontend/src/i18n/plurals.test.ts +++ b/frontend/src/i18n/plurals.test.ts @@ -6,6 +6,20 @@ import { describe, expect, it } from "vitest"; import i18n from "./index"; +const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record< + string, + { default: Record } +>; + +// Every leaf string as [dotted key, value]. +function leaves(obj: Record, prefix = ""): [string, string][] { + return Object.entries(obj).flatMap(([k, v]) => { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object") return leaves(v as Record, key); + return typeof v === "string" ? [[key, v] as [string, string]] : []; + }); +} + describe("counted copy", () => { it("the share link's expiry is singular for one day", async () => { await i18n.changeLanguage("en"); @@ -22,3 +36,26 @@ describe("counted copy", () => { expect(i18n.t("downloads.share.views", { count: 0 })).toBe("0 views"); }); }); + +// E31: "1 seasons", "Synced 1 subscriptions" — a counted string written as one plain key reads plural at +// one. So the rule is held for every string, not key by key: a value that carries {{count}} is a plural +// pair (`_one` / `_other`), even where the two read the same today, so the next wording is a plural too. +describe("every counted string is a plural pair", () => { + it("no {{count}} in a key without a plural suffix, in any language", () => { + const bare = Object.entries(mods).flatMap(([path, mod]) => + leaves(mod.default) + .filter(([k, v]) => v.includes("{{count}}") && !/_(zero|one|two|few|many|other)$/.test(k)) + .map(([k]) => `${path.replace(/^.*locales\//, "")}: ${k}`), + ); + expect(bare).toEqual([]); + }); + + it("reads singular at one", async () => { + await i18n.changeLanguage("en"); + expect(i18n.t("plex.seasons", { count: 1 })).toBe("1 season"); + expect(i18n.t("plex.seasons", { count: 3 })).toBe("3 seasons"); + expect(i18n.t("channels.notify.synced", { count: 1 })).toBe("Synced 1 subscription"); + expect(i18n.t("playlists.pushPartial", { count: 1 })).toBe("Synced, but 1 video was skipped"); + expect(i18n.t("playlists.pushPartial", { count: 2 })).toBe("Synced, but 2 videos were skipped"); + }); +}); From 78fdcda6bbca7965d1bf744edd88b3328c59ff23 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 02:13:17 +0200 Subject: [PATCH 09/31] =?UTF-8?q?feat(ui):=20E31=20S4c=20=E2=80=94=20the?= =?UTF-8?q?=20channel=20header=20folds,=20the=20Plex=20pages=20fill=20thei?= =?UTF-8?q?r=20hero,=20the=20player=20gets=20a=20side=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Channel page: the banner and identity block fold away once the grid scrolls (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds at the very top) and the name rides in the tab row. One Back button in both states keeps its focus; folded at a tablet width the actions wrap instead of pushing the page sideways. - Plex movie and show pages: the TMDB facts over the key crew are the hero's second column (plexDetailAside), the keywords sit under the text; the show page shows them for the first time, with the same three toggles. A collection's hero is sized to its text. - YouTube player, 1280px and wider: the title, channel, actions, description and queue sit in a side panel beside the video, which takes the window's height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel while it can and turns the volume elsewhere. - DESIGN.md decision log: the keywords' place under the text. --- docs/DESIGN.md | 1 + .../components/ChannelPage.fold.dom.test.tsx | 132 ++++ frontend/src/components/ChannelPage.tsx | 476 ++++++++------- .../src/components/PlayerModal.dom.test.tsx | 87 +++ frontend/src/components/PlayerModal.tsx | 523 +++++++++------- .../src/components/PlexCollectionView.tsx | 3 + frontend/src/components/PlexInfo.dom.test.tsx | 77 ++- frontend/src/components/PlexInfo.tsx | 575 ++++++------------ frontend/src/components/plexDetailAside.tsx | 218 +++++++ frontend/src/components/plexDetailUi.tsx | 24 +- .../components/plexShow.aside.dom.test.tsx | 105 ++++ frontend/src/components/plexViews.tsx | 22 + frontend/src/components/ui/scrollFold.test.ts | 24 + frontend/src/components/ui/scrollFold.ts | 60 ++ frontend/src/lib/playerModalHooks.ts | 14 +- frontend/src/lib/plexDetail.ts | 52 +- 16 files changed, 1526 insertions(+), 867 deletions(-) create mode 100644 frontend/src/components/ChannelPage.fold.dom.test.tsx create mode 100644 frontend/src/components/plexDetailAside.tsx create mode 100644 frontend/src/components/plexShow.aside.dom.test.tsx create mode 100644 frontend/src/components/ui/scrollFold.test.ts create mode 100644 frontend/src/components/ui/scrollFold.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a82749f6..fbc82c21 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -700,3 +700,4 @@ of a full-width panel and nothing on the right. | 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible | | 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page | | 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site | +| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it | diff --git a/frontend/src/components/ChannelPage.fold.dom.test.tsx b/frontend/src/components/ChannelPage.fold.dom.test.tsx new file mode 100644 index 00000000..03730394 --- /dev/null +++ b/frontend/src/components/ChannelPage.fold.dom.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +// +// E31 (the owner's pick): the channel page's chrome is fixed, and its banner + identity block kept ~40% of a +// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold +// away and the name rides in the tab row; back at the top they return. The Back button is one element in +// both states, so a focused Back keeps its focus through the fold. +import type { ReactNode } from "react"; +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, screen, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./navigationContext", () => ({ + useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), +})); +vi.mock("./Feed", () => ({ default: () => null })); +vi.mock("./ChannelAbout", () => ({ default: () => null })); +vi.mock("./ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import ChannelPage from "./ChannelPage"; +import { CONTENT_ID } from "./ui/landmarks"; +import { api, type ChannelDetail, type Me } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + document.getElementById(CONTENT_ID)?.remove(); + vi.restoreAllMocks(); +}); + +const CH = "UCqoAEDirJPjEUFcF2FklnBA"; +const BANNER = 'img[src^="https://yt3."]'; + +// The page's one scroller (PageScroller's
), with a scroll position jsdom does not lay out. +function scroller() { + const main = document.createElement("main"); + main.id = CONTENT_ID; + let top = 0; + Object.defineProperty(main, "scrollTop", { get: () => top, configurable: true }); + Object.defineProperty(main, "scrollHeight", { value: 6000, configurable: true }); + Object.defineProperty(main, "clientHeight", { value: 600, configurable: true }); + document.body.appendChild(main); + return (to: number) => + act(() => { + top = to; + main.dispatchEvent(new Event("scroll")); + }); +} + +// Renders the page and waits for the detail to land (the banner shows). +async function renderPage() { + vi.spyOn(api, "channelDetail").mockResolvedValue({ + id: CH, + title: "StarTalk", + banner_url: "https://yt3.googleusercontent.com/banner", + subscribed: true, + blocked: false, + hidden: false, + deep_requested: false, + backfill_done: true, + stored_videos: 10, + tag_ids: [], + } as unknown as ChannelDetail); + vi.spyOn(api, "tags").mockResolvedValue([] as never); + const view = render( + + {}} + onShowInFeed={() => {}} + /> + , + ); + await waitFor(() => expect(view.container.querySelector(BANNER)).not.toBeNull()); + return view; +} + +describe("ChannelPage — the header folds while the grid scrolls", () => { + it("drops the banner and puts the name in the tab row once scrolled, and brings them back at the top", async () => { + const scrollTo = scroller(); + const { container } = await renderPage(); + const banner = () => container.querySelector(BANNER); + expect(banner()).not.toBeNull(); + expect(screen.queryByTestId("channel-folded")).toBeNull(); + + scrollTo(400); + expect(banner()).toBeNull(); + expect(screen.getByTestId("channel-folded").textContent).toContain("StarTalk"); + + scrollTo(10); + expect(screen.queryByTestId("channel-folded")).not.toBeNull(); + scrollTo(0); + expect(banner()).not.toBeNull(); + expect(screen.queryByTestId("channel-folded")).toBeNull(); + }); + + it("keeps a focused Back focused through the fold", async () => { + const scrollTo = scroller(); + await renderPage(); + const back = screen.getByTestId("channel-back"); + back.focus(); + scrollTo(400); + expect(screen.getByTestId("channel-folded")).toBeTruthy(); + expect(document.activeElement).toBe(back); + }); +}); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 8fea4170..758a3b78 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -24,6 +24,7 @@ import { import Avatar from "./ui/Avatar"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; +import { useScrollFold } from "./ui/scrollFold"; import Feed from "./Feed"; import ChannelAboutContent from "./ChannelAbout"; import { PageToolbar } from "./ui/PageShell"; @@ -89,6 +90,10 @@ export default function ChannelPage({ // where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so // the toolbar lands under the tabs inside the fixed band rather than in the scroller. const [chromeSlot, setChromeSlot] = useState(null); + // The banner and the identity block fold away once the grid scrolls (E31, the owner's pick): the chrome + // is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row. + const foldable = useRef(null); + const folded = useScrollFold(foldable); // The banner comes from Google's image CDN, which refuses any request carrying a Referer from our // origin — every time, not only in a burst (measured 2026-09-26) — so the sends none // (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss, @@ -320,255 +325,292 @@ export default function ChannelPage({ : null, joined ? t("channel.joined", { date: joined }) : null, ].filter(Boolean) as string[]; + // The page's name and its state, shown in the identity block or, folded, in the tab row. + const heading = ( + + {name} + + ); + const stateBadge = ch?.blocked ? ( + + {t("channel.blockedBadge")} + + ) : ( + ch?.explored && + !ch?.subscribed && ( + + {t("channel.exploringBadge")} + + ) + ); // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its // whole chrome is fixed — wrapped in it portals into the shell's fixed band, so the - // banner/identity/tabs/toolbar stay put and only the video grid scrolls. + // tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile. const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}` : null; return ( <> - {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */} -
- {bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? ( - // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a - // low default size, so request a crisp wide version (=w1707) and object-cover the - // desktop "safe area" — the centre 2560×423 (~6:1) band. -
- per attempt; the ?r= cache-buster on a retry bypasses the - // browser's negative cache (googleusercontent accepts an extra query param). - key={bannerAttempt} - src={bannerSrc} - referrerPolicy="no-referrer" - alt="" - className={`w-full h-full object-cover object-center ${ - bannerLoaded === bannerSrc ? "" : "opacity-0" - }`} - onLoad={() => setBannerLoaded(bannerSrc)} - onError={() => { - if (bannerAttempt <= MAX_BANNER_RETRIES) { - window.setTimeout( - () => setBannerAttempt((a) => a + 1), - 500 * (bannerAttempt + 1), - ); - } - }} - /> -
- ) : ( - // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the - // overlapping avatar + the Back button have the same room and don't collide (a short bar - // let the -mt-8 avatar ride up into the Back button). -
- )} - -
- -
- {/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner - paints ABOVE it (the banner's positioned container would otherwise cover it). */} -
- -
-
- - {name} - - {ch?.blocked ? ( - - {t("channel.blockedBadge")} - +
+ {!folded && ( +
+ {/* Banner — OPTION C: inset, rounded card (not full-bleed) */} +
+ {bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? ( + // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a + // low default size, so request a crisp wide version (=w1707) and object-cover the + // desktop "safe area" — the centre 2560×423 (~6:1) band. +
+ per attempt; the ?r= cache-buster on a retry bypasses the + // browser's negative cache (googleusercontent accepts an extra query param). + key={bannerAttempt} + src={bannerSrc} + referrerPolicy="no-referrer" + alt="" + className={`w-full h-full object-cover object-center ${ + bannerLoaded === bannerSrc ? "" : "opacity-0" + }`} + onLoad={() => setBannerLoaded(bannerSrc)} + onError={() => { + if (bannerAttempt <= MAX_BANNER_RETRIES) { + window.setTimeout( + () => setBannerAttempt((a) => a + 1), + 500 * (bannerAttempt + 1), + ); + } + }} + /> +
) : ( - ch?.explored && - !ch?.subscribed && ( - - {t("channel.exploringBadge")} - - ) + // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the + // overlapping avatar + the Back button have the same room and don't collide (a short bar + // let the -mt-8 avatar ride up into the Back button). +
)}
-
- {metaParts.map((part, i) => ( - - {i > 0 && ·} - {part} - - ))} -
-
-
- {exploring && ( -
- - {t("channel.loadingVideos")} +
+ {/* Identity. relative+z-base so the avatar that overlaps up into the banner paints + ABOVE it (the banner's positioned container would otherwise cover it). */} +
+ +
+
+ {heading} + {stateBadge} +
+
+ {metaParts.map((part, i) => ( + + {i > 0 && ·} + {part} + + ))} +
+
+
+
)} - {/* Tabs — the channel actions ride along here rather than off in the header's top-right +
+ {exploring && ( +
+ + {t("channel.loadingVideos")} +
+ )} + + {/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} - {/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and + {/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */} -
- setTab(id as "videos" | "about")} - tabs={[ - { id: "videos", label: t("channel.tabVideos") }, - { id: "about", label: t("channel.tabAbout") }, - ]} - /> -
- + {/* One Back button in both states, so a focused Back keeps its focus when the header folds: + over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */} + - )} - {!me.is_demo && ( - block.mutate()} - loading={block.isPending} - label={ch?.blocked ? t("channel.unblock") : t("channel.block")} - > - - - )} - {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel + {ch?.subscribed && !ch.backfill_done && !me.is_demo && ( + + )} + {!me.is_demo && ( + block.mutate()} + loading={block.isPending} + label={ch?.blocked ? t("channel.unblock") : t("channel.block")} + > + + + )} + {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel (mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos from the feed. EyeOff = currently hidden (click to unhide). */} - {ch?.subscribed && !me.is_demo && ( - toggleHidden.mutate()} - loading={toggleHidden.isPending} - label={ch.hidden ? t("channel.unhide") : t("channel.hide")} - title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")} - > - {ch.hidden ? : } - - )} - {!me.is_demo && - !ch?.blocked && - (ch?.subscribed ? ( - - ) : ( - - ))} - {/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s + {ch.hidden ? : } + + )} + {!me.is_demo && + !ch?.blocked && + (ch?.subscribed ? ( + + ) : ( + + ))} + {/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s otherwise), and personal tags are for your subscriptions — so only offer it on a channel you actually follow. */} - {ch?.subscribed && !me.is_demo && ( -
- toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} - onCreate={(name) => createAndAttachTag(channelId, name)} - // The header row's leftover width: tags inline, "+N" only when they do not fit. - bounded={false} - /> -
- )} + {ch?.subscribed && !me.is_demo && ( +
+ toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} + onCreate={(name) => createAndAttachTag(channelId, name)} + // The header row's leftover width: tags inline, "+N" only when they do not fit. + bounded={false} + /> +
+ )} +
diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 10649ea6..098759d6 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -468,3 +468,90 @@ describe("PlayerModal — fits the window's height", () => { expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))"); }); }); + +// E31 (the owner's pick "A"): on a wide window the video was capped at ~1008 px of a 1920 screen, with +// empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side +// panel beside the video, and the video takes the window's height. +describe("PlayerModal — the side panel on a wide window", () => { + const wideWindow = (matches: boolean) => + vi.spyOn(window, "matchMedia").mockImplementation( + (q: string) => + ({ + matches: matches && q === "(min-width: 1280px)", + media: q, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList, + ); + beforeEach(() => { + window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia; + }); + + it("puts the details beside the video, the description open, and sizes the card for both", async () => { + wideWindow(true); + vi.spyOn(api, "videoDetail").mockResolvedValue({ + description: "A long description", + } as Awaited>); + const A = vid("A", 0); + const B = vid("B", 0); + renderModal( + {}} onState={() => {}} />, + ); + const stage = document.querySelector(".player-stage")!; + const card = stage.parentElement!; + const info = screen.getByTestId("player-info"); + // The card is a row: the video, then the panel holding the title and the channel. + expect(card.className).toMatch(/(^|\s)flex(\s|$)/); + expect(card.contains(info) && !stage.contains(info)).toBe(true); + expect(info.contains(screen.getByRole("heading", { name: "Video A" }))).toBe(true); + expect(stage.compareDocumentPosition(info) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // The description is shown outright; there is no ⓘ to open it. + expect(await screen.findByText("A long description")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "player.description" })).toBeNull(); + // The row's width: the video at the window's height, plus the panel and the side strips. + expect(card.parentElement!.style.maxWidth).toBe( + "calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)", + ); + }); + + it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => { + wideWindow(true); + vi.spyOn(api, "videoDetail").mockResolvedValue({ + description: "A long description", + } as Awaited>); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + const text = await screen.findByText("A long description"); + const panel = screen.getByTestId("player-info"); + let top = 0; + Object.defineProperty(panel, "scrollTop", { get: () => top, configurable: true }); + Object.defineProperty(panel, "scrollHeight", { value: 900, configurable: true }); + Object.defineProperty(panel, "clientHeight", { value: 500, configurable: true }); + const spin = (el: Element, deltaY: number) => { + const e = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true }); + el.dispatchEvent(e); + return e.defaultPrevented; // prevented = the wheel went to the volume + }; + expect(spin(text, 100)).toBe(false); // room below: the panel scrolls + expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume + top = 400; + expect(spin(text, 100)).toBe(true); // at its end: the volume + expect(spin(text, -100)).toBe(false); + expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video + }); + + it("keeps the details under the video below 1280 px", () => { + wideWindow(false); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + const card = document.querySelector(".player-stage")!.parentElement!; + expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/); + expect(screen.getByRole("button", { name: "player.description" })).toBeTruthy(); + expect(screen.queryByTestId("player-description")).toBeNull(); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index c066371f..526992b7 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -76,6 +76,7 @@ import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { IconLink, LinkButton } from "./ui/LinkButton"; import { SectionLabel } from "./ui/Headings"; +import { useMediaQuery } from "../lib/useMediaQuery"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position @@ -98,6 +99,20 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { return false; } +// From this window width the player's details sit in a side panel beside the video (E31). The panel is +// a rem width, so it grows with the text size; the video takes what is left. +const WIDE_PLAYER_PX = 1280; +const SIDE_PANEL = "20rem"; + +// The meta line's items each lead with a "·" that parts them from what stands before: the channel name +// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing. +const metaSep = ( + +); +const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden"; + export default function PlayerModal({ video, startAt, @@ -194,6 +209,10 @@ export default function PlayerModal({ // full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's // height, measured (it wraps with the title and the text size), and caps the card's width below. const [below, setBelow] = useState(0); + // A wide window (E31, the owner's pick "A"): the title, channel, actions, description and the queue + // move into a side panel beside the video, so the video takes the window's whole height — at + // 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it. + const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false); useLayoutEffect(() => { const card = cardRef.current; const stage = stageRef.current; @@ -450,7 +469,8 @@ export default function PlayerModal({ queryFn: () => api.videoDetail(currentVideoId), // On opening the description, and eagerly when navigated, so we can link the // linked video's channel. Both share the one cached result. - enabled: showDesc || navigated, + // On a wide window the side panel shows the description outright (a stored video's is a DB read). + enabled: showDesc || navigated || wide, staleTime: 5 * 60_000, }); @@ -609,10 +629,13 @@ export default function PlayerModal({ className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`} // The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the // chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem). + // Wide: no design cap — the video is as wide as the window's height allows, plus the side panel. style={{ - maxWidth: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${ - hasQueue ? " + 7rem" : "" - }))`, + maxWidth: wide + ? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})` + : `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${ + hasQueue ? " + 7rem" : "" + }))`, }} > {hasQueue && ( @@ -632,14 +655,16 @@ export default function PlayerModal({
e.stopPropagation()} >
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed through our overlay. */} @@ -798,234 +823,284 @@ export default function PlayerModal({ )}
- {hasQueue && ( -
- -
- )} - -
- {/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */} -
-

- {navigated ? (liveData?.title ?? t("player.loading")) : active.title} -

- - - - {navigated && ( - + {/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long + description never makes the card taller than the video. */} +
+
+ {hasQueue && ( +
+ +
)} - - - {t("player.description")} - - {detail.isLoading ? ( -
{t("player.loading")}
- ) : detail.data?.description ? ( -
- {renderDescription(detail.data.description, { - currentId: currentVideoId, - onSeek: seekTo, - onLoadVideo: loadVideo, - t, - })} -
- ) : ( -
{t("player.noDescription")}
- )} -
- -
- {/* Channel + meta on one line, with the watched toggle pushed to the right. +
+ {/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */} +
+

+ {navigated ? (liveData?.title ?? t("player.loading")) : active.title} +

+ {!wide && ( + + + + )} + {navigated && ( + + )} + + + {t("player.description")} + + {detail.isLoading ? ( +
{t("player.loading")}
+ ) : detail.data?.description ? ( +
+ {renderDescription(detail.data.description, { + currentId: currentVideoId, + onSeek: seekTo, + onLoadVideo: loadVideo, + t, + })} +
+ ) : ( +
{t("player.noDescription")}
+ )} +
+ +
+ + {/* Channel + meta on one line, with the watched toggle pushed to the right. When navigated to a linked video we only have its author (from the player), so we show that as plain text and hide feed-video-specific bits. */} -
- {!navigated && ( - - )} - {navigated ? ( - detail.data?.channel_id ? ( - - {liveData?.author ?? detail.data.channel_title ?? t("player.channel")} - - ) : ( - {liveData?.author ?? ""} - ) - ) : ( -
- { - // openChannel closes this player first and pushes only once its - // Back-to-close entry is reclaimed — otherwise that reclaim undoes the push - // and clicking the name merely closes the player - // (lib/history.closeOverlaysThenNavigate). - openChannel(active.channel_id, active.channel_title ?? undefined); - }} - className="font-medium hover:text-accent truncate text-left" - /> - - - -
- )} - {!navigated ? ( -
- {active.view_count != null && ( - - ·{" "} - {t("player.views", { - count: active.view_count, - formattedCount: formatCompact(active.view_count, lang), - })} - +
+ {!navigated && ( + )} - - · {relativeTime(active.published_at, lang)} - {active.published_at && ` · ${fullDate(active.published_at)}`} - - {active.duration_seconds != null && ( - · {formatDuration(active.duration_seconds)} + {navigated ? ( + detail.data?.channel_id ? ( + + {liveData?.author ?? detail.data.channel_title ?? t("player.channel")} + + ) : ( + {liveData?.author ?? ""} + ) + ) : ( +
+ { + // openChannel closes this player first and pushes only once its + // Back-to-close entry is reclaimed — otherwise that reclaim undoes the push + // and clicking the name merely closes the player + // (lib/history.closeOverlaysThenNavigate). + openChannel(active.channel_id, active.channel_title ?? undefined); + }} + className="font-medium hover:text-accent truncate text-left" + /> + + + +
)} - {active.live_status === "was_live" && ( - - {t("player.stream")} - + {!navigated ? ( +
+ {active.view_count != null && ( + + {metaSep} + {t("player.views", { + count: active.view_count, + formattedCount: formatCompact(active.view_count, lang), + })} + + )} + + {metaSep} + {relativeTime(active.published_at, lang)} + {active.published_at && ` · ${fullDate(active.published_at)}`} + + {active.duration_seconds != null && ( + + {metaSep} + {formatDuration(active.duration_seconds)} + + )} + {active.live_status === "was_live" && ( + + {t("player.stream")} + + )} +
+ ) : ( + // Linked video's stats come from the (already-fetched) video detail. +
+ {detail.data?.view_count != null && ( + + {metaSep} + {t("player.views", { + count: detail.data.view_count, + formattedCount: formatCompact(detail.data.view_count, lang), + })} + + )} + {detail.data?.published_at && ( + + {metaSep} + {relativeTime(detail.data.published_at, lang)} ·{" "} + {fullDate(detail.data.published_at)} + + )} + {detail.data?.duration_seconds != null && ( + + {metaSep} + {formatDuration(detail.data.duration_seconds)} + + )} +
)} -
- ) : ( - // Linked video's stats come from the (already-fetched) video detail. -
- {detail.data?.view_count != null && ( - - ·{" "} - {t("player.views", { - count: detail.data.view_count, - formattedCount: formatCompact(detail.data.view_count, lang), - })} - - )} - {detail.data?.published_at && ( - - · {relativeTime(detail.data.published_at, lang)} ·{" "} - {fullDate(detail.data.published_at)} - - )} - {detail.data?.duration_seconds != null && ( - · {formatDuration(detail.data.duration_seconds)} - )} -
- )} - {/* Reliable, discoverable path into our big view straight from the windowed player — + {/* Reliable, discoverable path into our big view straight from the windowed player — the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and the only fullscreen button previously reachable in the windowed video was YouTube's own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there the docked bar offers OUR true fullscreen. */} - {!navigated && ( - - - - )} - {!navigated && } - {!navigated && ( - - )} - {!navigated && onRemoveFromList && ( - void removeFromList()} - // Named with its object, like the list's menu row: a bare "Remove" among Fullscreen / - // Download / Mark watched reads as "delete this video". - label={t("playlists.removeItemOf", { title: active.title })} - title={t("playlists.removeItem")} - > - - - )} - {!navigated && ( - - )} + {!navigated && ( + + + + )} + {!navigated && } + {!navigated && ( + + )} + {!navigated && onRemoveFromList && ( + void removeFromList()} + // Named with its object, like the list's menu row: a bare "Remove" among Fullscreen / + // Download / Mark watched reads as "delete this video". + label={t("playlists.removeItemOf", { title: active.title })} + title={t("playlists.removeItem")} + > + + + )} + {!navigated && ( + + )} +
+ {wide && ( +
+ + {t("player.description")} + + {detail.isLoading ? ( +
{t("player.loading")}
+ ) : detail.data?.description ? ( +
+ {renderDescription(detail.data.description, { + currentId: currentVideoId, + onSeek: seekTo, + onLoadVideo: loadVideo, + t, + })} +
+ ) : ( +
{t("player.noDescription")}
+ )} +
+ )} +
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index caa640e0..eef531e0 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -286,6 +286,9 @@ export default function PlexCollectionView({ posterDisabled={!canPlay || queueBusy} showPlayOverlay={canPlay} onPosterClick={() => canPlay && !queueBusy && playAll(false)} + // Sized to the text beside it (E31, the owner's pick): a title, one meta line and a short + // description sat beside a show-sized poster, two thirds of the hero empty under them. + posterSizeClass="w-20 sm:w-24" customizeMenu={null} > {renaming ? ( diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index bbd5cc8f..90b958bf 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -174,8 +174,11 @@ describe("PlexInfo enriched detail (page)", () => { // E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of // facts (details, keywords); a column with nothing in it is not rendered and the other takes the width. -describe("PlexInfo layout B (page)", () => { - const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]"; +// E31 (the owner's pick "A"): the hero's text stopped at ~55% of the panel. The facts and the key crew are +// its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under +// the hero only the collection strips and the cast remain, stacked. +describe("PlexInfo — the hero's second column (page)", () => { + const TWO_COLUMNS = "@5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]"; // The strips are Carousels, which watch their size. beforeAll(() => { class Stub { @@ -186,7 +189,8 @@ describe("PlexInfo layout B (page)", () => { globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; }); const heading = (name: string) => screen.getByRole("heading", { name }); - const column = (el: HTMLElement) => el.closest("div.flex-col") as HTMLElement; + const follows = (a: HTMLElement, b: HTMLElement) => + !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); const full: PlexItemDetail = { ...detail, cast: [{ name: "An Actor", role: "Hero" }], @@ -200,38 +204,51 @@ describe("PlexInfo layout B (page)", () => { ], }; - it("stacks the collection, the crew and the cast in the main column, the facts beside it", () => { + it("puts the facts over the crew beside the text, the keywords under it, the rest below the hero", () => { wrap(); + const aside = screen.getByTestId("detail-aside"); + expect(aside.contains(heading("plex.info.facts.title"))).toBe(true); + expect(aside.contains(heading("plex.info.crew"))).toBe(true); + expect(follows(heading("plex.info.facts.title"), heading("plex.info.crew"))).toBe(true); + // The grid's two children: the poster + text, and the column. + const grid = aside.parentElement!; + expect(grid.className).toContain(TWO_COLUMNS); + const main = grid.children[0]!; + const side = grid.children[1]; + expect(side).toBe(aside); + expect(main.contains(screen.getByText("A summary."))).toBe(true); + expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true); + // Below the hero (the panel under the title): the collection, then the cast — and neither in it. + const hero = grid.parentElement!; + expect(hero.contains(heading("Test Movie"))).toBe(true); const coll = heading("The Collection"); - const crew = heading("plex.info.crew"); const cast = heading("plex.info.cast"); - const facts = heading("plex.info.facts.title"); - const follows = (a: HTMLElement, b: HTMLElement) => - !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); - expect(follows(coll, crew) && follows(crew, cast)).toBe(true); - const main = column(coll); - expect(main.contains(crew) && main.contains(cast)).toBe(true); - const side = column(facts); - expect(side).not.toBe(main); - expect(side).toBe(column(heading("plex.info.keywords"))); - // the two columns are the two children of the one grid - expect(main.parentElement!.className).toContain(TWO_COLUMNS); - expect([...main.parentElement!.children]).toEqual([main, side]); + expect(hero.contains(coll) || hero.contains(cast)).toBe(false); + expect(follows(hero, coll) && follows(coll, cast)).toBe(true); }); - it("with nothing for the main column, the sidebar's sections take the full width", () => { - wrap( - , - ); - expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull(); - const side = column(heading("plex.info.facts.title")); - expect(side).toBe(column(heading("plex.info.keywords"))); - expect(side.parentElement!.className).not.toContain(TWO_COLUMNS); - expect([...side.parentElement!.children]).toEqual([side]); + it("without facts or crew there is no second column; the keywords still sit under the text", () => { + const bare: PlexItemDetail = { + ...detail, + year: null, + duration_seconds: null, + content_rating: null, + studio: null, + tmdb: { + ...detail.tmdb!, + budget: null, + revenue: null, + studios: [], + production_countries: [], + crew: [], + }, + }; + wrap(); + expect(screen.queryByTestId("detail-aside")).toBeNull(); + expect(screen.queryByRole("heading", { name: "plex.info.facts.title" })).toBeNull(); + const kw = screen.getByTestId("detail-keywords"); + expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull(); + expect(follows(heading("Test Movie"), kw)).toBe(true); }); }); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index c4f589aa..1895f1f6 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -1,14 +1,19 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api"; -import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format"; +import { formatCompact, formatDecimal, formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; -import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; -import { usePref } from "../lib/usePref"; +import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; +import { + tmdbBackdropUrl, + useCrossfadeBackdrop, + useDetailAside, + useDetailPrefs, +} from "../lib/plexDetail"; import { Carousel } from "./ui/Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; @@ -21,13 +26,12 @@ import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { IconLink } from "./ui/LinkButton"; import { PageHeading } from "./ui/Headings"; -import clsx from "clsx"; import { PlexPosterCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions } from "./usePlexCardActions"; import { toggleWatchState } from "../lib/plexWatch"; -import { regionName, vocab } from "../lib/vocab"; +import { vocab } from "../lib/vocab"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -143,174 +147,6 @@ function RatingsRow({ ); } -// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries, -// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something. -// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule). -function FactsPanel({ - detail, - onFilter, -}: { - detail: PlexItemDetail; - onFilter?: (patch: Partial) => void; -}) { - const { t, i18n } = useTranslation(); - const lang = i18n.language; - const tmdb = detail.tmdb; - const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : []; - const rows: { key: string; label: string; node: ReactNode }[] = []; - // The same values filter here as in the header above (year, certificate, studios). - const year = detail.year; - if (year != null) - rows.push({ - key: "year", - label: t("plex.info.facts.release"), - node: ( - onFilter({ yearMin: year, yearMax: year }) : undefined} - > - {year} - - ), - }); - if (detail.duration_seconds) - rows.push({ - key: "runtime", - label: t("plex.info.facts.runtime"), - node: formatRuntime(detail.duration_seconds, lang), - }); - const cert = detail.content_rating; - if (cert) - rows.push({ - key: "cert", - label: t("plex.info.facts.cert"), - node: ( - onFilter({ contentRatings: [cert] }) : undefined}> - {vocab(t, "certificate", cert)} - - ), - }); - if (tmdb?.budget) - rows.push({ - key: "budget", - label: t("plex.info.facts.budget"), - node: formatMoney(tmdb.budget, lang), - }); - if (tmdb?.revenue) - rows.push({ - key: "revenue", - label: t("plex.info.facts.revenue"), - node: formatMoney(tmdb.revenue, lang), - }); - if (studios.length) - rows.push({ - key: "studios", - label: t("plex.info.facts.studios"), - node: ( - - {studios.map((s) => ( - onFilter({ studios: [s] }) : undefined} - > - {s} - - ))} - - ), - }); - if (tmdb?.production_countries?.length) - rows.push({ - key: "countries", - label: t("plex.info.facts.countries"), - node: tmdb.production_countries - .map((c) => regionName(t, c.code, i18n.language, c.name)) - .join(", "), - }); - if (rows.length === 0) return null; - return ( -
-

{t("plex.info.facts.title")}

-
- {rows.map((r) => ( -
-
{r.label}
-
{r.node}
-
- ))} -
-
- ); -} - -// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter). -function KeywordsPanel({ - keywords, - onFilter, -}: { - keywords: string[]; - onFilter?: (patch: Partial) => void; -}) { - const { t } = useTranslation(); - if (keywords.length === 0) return null; - return ( -
-

{t("plex.info.keywords")}

-
- {keywords.map((k) => ( - onFilter({ keywords: [k] }) : undefined} - > - {k} - - ))} -
-
- ); -} - -// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open -// their person page. Complements the hero's Director line (which comes from Plex directly). -function CrewPanel({ - crew, - onOpenPerson, -}: { - crew: { name: string; job: string }[]; - onOpenPerson?: (name: string) => void; -}) { - const { t } = useTranslation(); - if (crew.length === 0) return null; - return ( -
-

{t("plex.info.crew")}

- {/* In the main column (layout B): as many name columns as its width holds. */} -
- {crew.map((c, i) => ( -
- {onOpenPerson ? ( - onOpenPerson(c.name)} - title={t("plex.info.viewPerson", { name: c.name })} - className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)" - > - {c.name} - - ) : ( -
{c.name}
- )} -
{vocab(t, "crewJob", c.job)}
-
- ))} -
-
- ); -} - // The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when // known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses, // so the two surfaces can't drift. `overlay` tints the label for the dark player background. @@ -372,10 +208,8 @@ export default function PlexInfo({ // is PlexInfo-specific state, persisted through the same savePref. const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } = useDetailPrefs(); - // The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row. - const [showFacts, setShowFacts] = usePref("plexInfoFacts", true); - const [showKeywords, setShowKeywords] = usePref("plexInfoKeywords", true); - const [showCrew, setShowCrew] = usePref("plexInfoCrew", true); + // The TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle. + const aside = useDetailAside(detail); const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { plexHiddenStripSources?: string[] }; // Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled @@ -462,20 +296,7 @@ export default function PlexInfo({ const sourceLabel = (src: string) => t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); - // Whether each enriched side panel has anything to show — drives both its render and its toggle row - // (no toggle for a panel that would be empty anyway). - const tmdb = detail.tmdb; - const hasFacts = - detail.year != null || - !!detail.duration_seconds || - !!detail.content_rating || - !!tmdb?.budget || - !!tmdb?.revenue || - !!tmdb?.studios?.length || - !!detail.studio || - !!tmdb?.production_countries?.length; - const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0; - const hasCrew = (tmdb?.crew?.length ?? 0) > 0; + const asideShown = !overlay && aside.column; const infoTitle = detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title; @@ -483,7 +304,7 @@ export default function PlexInfo({
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */} -
+
{/* Header: close (overlay) / customize */}
@@ -510,27 +331,7 @@ export default function PlexInfo({ hasCast={detail.cast.length > 0} extra={ <> - {hasFacts && ( - setShowFacts(!showFacts)} - /> - )} - {hasKeywords && ( - setShowKeywords(!showKeywords)} - /> - )} - {hasCrew && ( - setShowCrew(!showCrew)} - /> - )} + {!overlay && } {presentSources.map((src) => ( -
- {/* Poster */} - + {/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */} +
+
+ {/* Poster */} + -
- {/* Meta line + IMDb rating/link (each value can set the matching filter). */} -
- {detail.year != null && ( - onFilter({ yearMin: detail.year, yearMax: detail.year }) - : undefined - } - > - {detail.year} - - )} - {detail.content_rating && ( - onFilter({ contentRatings: [detail.content_rating!] }) - : undefined - } - > - {vocab(t, "certificate", detail.content_rating)} - - )} - {detail.duration_seconds ? ( - {formatRuntime(detail.duration_seconds, lang)} - ) : null} - {detail.studio && ( - onFilter({ studios: [detail.studio!] }) : undefined} - > - {detail.studio} - - )} - -
- - {/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} -
- onFilter({ ratingMin: n }) : undefined} - onDark={overlay} - /> -
- - {detail.tagline && ( -

- {detail.tagline} -

- )} - - {detail.genres.length > 0 && ( -
- {detail.genres.map((g) => ( +
+ {/* Meta line + IMDb rating/link (each value can set the matching filter). */} +
+ {detail.year != null && ( onFilter({ genres: [g] }) : undefined} + title={ + onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined + } + onClick={ + onFilter + ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) + : undefined + } > - {g} + {detail.year} - ))} -
- )} - - {detail.directors.length > 0 && ( -

- {t("plex.info.director")}: - {detail.directors.map((d) => ( + )} + {detail.content_rating && ( onFilter({ directors: [d] }) : undefined} + onClick={ + onFilter + ? () => onFilter({ contentRatings: [detail.content_rating!] }) + : undefined + } > - {d} + {vocab(t, "certificate", detail.content_rating)} - ))} -

- )} - - {detail.summary && ( -

- {detail.summary} -

- )} - - {/* Page actions: Play/Resume + watch controls */} - {!overlay && ( -
- {onPlay && ( - )} - {detail.status === "watched" ? ( - - ) : ( - - )} - {inProgress && ( - - )} - {detail.kind === "movie" && ( - - )} - {isAdmin && detail.kind === "movie" && library && ( - + {detail.studio} + )} +
- )} + + {/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} +
+ onFilter({ ratingMin: n }) : undefined} + onDark={overlay} + /> +
+ + {detail.tagline && ( +

+ {detail.tagline} +

+ )} + + {detail.genres.length > 0 && ( +
+ {detail.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + + {detail.directors.length > 0 && ( +

+ {t("plex.info.director")}: + {detail.directors.map((d) => ( + onFilter({ directors: [d] }) : undefined} + > + {d} + + ))} +

+ )} + + {detail.summary && ( +

+ {detail.summary} +

+ )} + + {/* Page actions: Play/Resume + watch controls */} + {!overlay && ( +
+ {onPlay && ( + + )} + {detail.status === "watched" ? ( + + ) : ( + + )} + {inProgress && ( + + )} + {detail.kind === "movie" && ( + + )} + {isAdmin && detail.kind === "movie" && library && ( + + )} +
+ )} + {!overlay && aside.shown.keywords && ( + + )} +
+ {asideShown && ( + + )}
{/* End hero panel. */} - {/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection - strips first (the owner's most used), then the crew, then the cast — beside a narrow - sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user - toggleable, so the layout is built from what is ON: a column with nothing in it is not - rendered and the other takes the full width; inside a column the sections stack, so a - hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The - overlay (over the running player) stays lean: its strips and its cast, stacked. */} + {/* Below the hero: the collection strips first (the owner's most used), then the cast, each + per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that + sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over + the running player) is the same stack, lean. */} {(() => { const collections = (detail.collections ?? []).filter( (col) => !hiddenSources.includes(col.source), @@ -845,38 +667,11 @@ export default function PlexInfo({ {castRow} ); - const crewBox = showCrew && hasCrew && ( - - ); - const factsBox = showFacts && hasFacts && ( - - ); - const keywordsBox = showKeywords && hasKeywords && ( - - ); - const hasMain = strips.length > 0 || !!crewBox || !!castRow; - const hasSide = !!factsBox || !!keywordsBox; - if (!hasMain && !hasSide) return null; + if (strips.length === 0 && !castRow) return null; return ( -
- {hasMain && ( -
- {strips} - {crewBox} - {castRow} -
- )} - {hasSide && ( -
- {factsBox} - {keywordsBox} -
- )} +
+ {strips} + {castRow}
); })()} diff --git a/frontend/src/components/plexDetailAside.tsx b/frontend/src/components/plexDetailAside.tsx new file mode 100644 index 00000000..977dbc38 --- /dev/null +++ b/frontend/src/components/plexDetailAside.tsx @@ -0,0 +1,218 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { PlexFilters } from "../lib/api"; +import { formatMoney, formatRuntime } from "../lib/format"; +import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail"; +import { regionName, vocab } from "../lib/vocab"; +import { Filterable, PrefToggle } from "./plexDetailUi"; +import { Pressable } from "./ui/Pressable"; + +// The detail pages' second column (E31, the owner's pick "A"): the TMDB facts and key crew of a movie or +// a show beside its description, inside the hero — as the person page sets its known-for rail beside +// the bio (E30 S6) — and its keywords under the text. They used to sit under the hero (movie) or +// nowhere at all (show), while the hero's text stopped at ~55% of the panel and left the rest empty. A +// narrow hero stacks the column under the text. Each section is a per-user toggle (default ON), the same +// three prefs on both pages (lib/plexDetail useDetailAside). + +type OnFilter = (patch: Partial) => void; + +// The customize menu's rows: one per section that has something (no toggle for an empty one). +export function DetailAsideToggles({ aside }: { aside: DetailAsideState }) { + const { t } = useTranslation(); + const label: Record = { + facts: t("plex.info.prefFacts"), + keywords: t("plex.info.prefKeywords"), + crew: t("plex.info.prefCrew"), + }; + return (["facts", "keywords", "crew"] as const) + .filter((k) => aside.has[k]) + .map((k) => ( + aside.toggle(k)} /> + )); +} + +// The hero's second column: the facts over the key crew — two lists, read down. Null when neither is +// shown. The keywords are chips that filter, as the genres are, so they sit under the text instead +// (DetailKeywords): all three in this column ran it to twice the text's height at 20 keywords and 12 +// crew, a hole under the text — the measured worst case (E31 S4c). +export function DetailAside({ + facts, + shown, + onFilter, + onOpenPerson, +}: { + facts: DetailFacts; + shown: DetailAsideState["shown"]; + onFilter?: OnFilter; + onOpenPerson?: (name: string) => void; +}) { + if (!shown.facts && !shown.crew) return null; + return ( +
+ {shown.facts && } + {shown.crew && } +
+ ); +} + +// The structured facts (E5 S5): release / runtime / cert / budget / revenue / studios / countries, from +// the title's own fields + the persisted TMDB block. What filters the library is an edged chip, a plain +// fact stays flat text (E28 S3h, the S3d1 rule). +function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFilter }) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const tmdb = facts.tmdb; + const studios = tmdb?.studios?.length ? tmdb.studios : facts.studio ? [facts.studio] : []; + const rows: { key: string; label: string; node: ReactNode }[] = []; + // The same values filter here as in the hero's meta line (year, certificate, studios). + const year = facts.year; + if (year != null) + rows.push({ + key: "year", + label: t("plex.info.facts.release"), + node: ( + onFilter({ yearMin: year, yearMax: year }) : undefined} + > + {year} + + ), + }); + if (facts.duration_seconds) + rows.push({ + key: "runtime", + label: t("plex.info.facts.runtime"), + node: formatRuntime(facts.duration_seconds, lang), + }); + const cert = facts.content_rating; + if (cert) + rows.push({ + key: "cert", + label: t("plex.info.facts.cert"), + node: ( + onFilter({ contentRatings: [cert] }) : undefined}> + {vocab(t, "certificate", cert)} + + ), + }); + if (tmdb?.budget) + rows.push({ + key: "budget", + label: t("plex.info.facts.budget"), + node: formatMoney(tmdb.budget, lang), + }); + if (tmdb?.revenue) + rows.push({ + key: "revenue", + label: t("plex.info.facts.revenue"), + node: formatMoney(tmdb.revenue, lang), + }); + if (studios.length) + rows.push({ + key: "studios", + label: t("plex.info.facts.studios"), + node: ( + + {studios.map((s) => ( + onFilter({ studios: [s] }) : undefined} + > + {s} + + ))} + + ), + }); + if (tmdb?.production_countries?.length) + rows.push({ + key: "countries", + label: t("plex.info.facts.countries"), + node: tmdb.production_countries + .map((c) => regionName(t, c.code, i18n.language, c.name)) + .join(", "), + }); + if (rows.length === 0) return null; + return ( +
+

{t("plex.info.facts.title")}

+
+ {rows.map((r) => ( +
+
{r.label}
+
{r.node}
+
+ ))} +
+
+ ); +} + +// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword. Under the hero's text. +export function DetailKeywords({ + keywords, + onFilter, + className, +}: { + keywords: string[]; + onFilter?: OnFilter; + className?: string; +}) { + const { t } = useTranslation(); + return ( +
+

{t("plex.info.keywords")}

+
+ {keywords.map((k) => ( + onFilter({ keywords: [k] }) : undefined} + > + {k} + + ))} +
+
+ ); +} + +// The TMDB key crew (E5 S5) — Director / Writer / Producer / …, each opening their person page. It +// complements the hero's Director line (which comes from Plex directly). +function CrewSection({ + crew, + onOpenPerson, +}: { + crew: { name: string; job: string }[]; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+

{t("plex.info.crew")}

+ {/* As many name columns as the column holds: two in the hero's side column, one when narrow. */} +
+ {crew.map((c, i) => ( +
+ {onOpenPerson ? ( + onOpenPerson(c.name)} + title={t("plex.info.viewPerson", { name: c.name })} + className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)" + > + {c.name} + + ) : ( +
{c.name}
+ )} +
{vocab(t, "crewJob", c.job)}
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index 52e15bf2..bab037fc 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -230,7 +230,9 @@ export function ActionBtn({ /** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were * copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot, * and the flex info column; the caller passes the customize menu + the title/metadata/actions as - * `children`. Poster is slightly larger on the show page (default) than the season page. */ + * `children`. Poster is slightly larger on the show page (default) than the season page. `aside` is the + * show page's second column (the facts and crew, plexDetailAside): beside the text in a wide hero, + * under it in a narrow one. */ export function DetailHero({ thumb, posterTitle, @@ -239,6 +241,7 @@ export function DetailHero({ onPosterClick, posterSizeClass = "w-32 sm:w-44", customizeMenu, + aside, children, }: { thumb: string; @@ -248,11 +251,11 @@ export function DetailHero({ onPosterClick: () => void; posterSizeClass?: string; customizeMenu: ReactNode; + aside?: ReactNode; children: ReactNode; }) { - return ( -
-
{customizeMenu}
+ const main = ( +
{children}
); + return ( +
+
{customizeMenu}
+ {aside ? ( +
+ {main} + {aside} +
+ ) : ( + main + )} +
+ ); } /** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both diff --git a/frontend/src/components/plexShow.aside.dom.test.tsx b/frontend/src/components/plexShow.aside.dom.test.tsx new file mode 100644 index 00000000..79fb4368 --- /dev/null +++ b/frontend/src/components/plexShow.aside.dom.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +// +// E31 (the owner's pick "A"): TMDB enriches shows as it does movies — studios, countries, keywords, key +// crew — and the show page showed none of it, beside a hero whose text stopped at ~55% of the panel. Its +// hero now has the movie page's second column (facts over crew) and the keywords under the text. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; +}); + +const detail = { + show: { + id: "show", + title: "Fargo", + summary: "Various chronicles.", + year: 2014, + thumb: "", + art: "", + content_rating: "TV-MA", + studio: "26 Keys", + genres: ["Crime"], + cast: [], + status: "new", + episode_count: 0, + collection_keys: [], + tmdb: { + keywords: ["minnesota", "winter"], + crew: [{ name: "Noah Hawley", job: "Creator" }], + studios: ["26 Keys Productions", "MGM Television"], + production_countries: [{ code: "US", name: "United States" }], + recommendations: [], + backdrops: [], + }, + }, + seasons: [], + related: [], +}; + +vi.mock("../lib/api", () => ({ + api: { + plexShow: () => Promise.resolve(detail), + updatePrefs: () => Promise.resolve({}), + }, + EMPTY_PLEX_FILTERS: {}, + plexFilterCount: () => 0, +})); + +import { PlexShowView } from "./plexViews"; + +afterEach(cleanup); + +function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} + onPlay={() => {}} + onOpenSeason={() => {}} + onOpenShow={() => {}} + onFilter={onFilter} + onOpenPerson={onOpenPerson} + /> + , + ); + return { onFilter, onOpenPerson }; +} + +describe("the show page's facts, keywords and crew (E31)", () => { + it("shows the facts over the crew beside the text, and the keywords under it", async () => { + const { onFilter, onOpenPerson } = renderShow(); + const aside = await screen.findByTestId("detail-aside"); + expect(aside.textContent).toContain("MGM Television"); + expect(aside.textContent).toContain("Noah Hawley"); + expect(aside.parentElement!.className).toContain("@5xl:grid-cols-"); + const main = aside.parentElement!.children[0]!; + expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true); + expect(main.contains(screen.getByText("Various chronicles."))).toBe(true); + // They act as on the movie page: a keyword filters, a crew name opens the person. + fireEvent.click(screen.getByText("minnesota")); + expect(onFilter).toHaveBeenCalledWith({ keywords: ["minnesota"] }); + fireEvent.click(screen.getByText("Noah Hawley")); + expect(onOpenPerson).toHaveBeenCalledWith("Noah Hawley"); + }); +}); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 189aa715..14542f66 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -34,12 +34,14 @@ import { Filterable, WatchedToggle, } from "./plexDetailUi"; +import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; import { epLabel, seasonTitle, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs, + useDetailAside, } from "../lib/plexDetail"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; @@ -449,6 +451,8 @@ export function PlexShowView({ toggleRelated, toggleCrossfade, } = useDetailPrefs(); + // The facts / keywords / crew (E31): TMDB enriches shows too, and the page showed none of it. + const aside = useDetailAside(show ?? {}); useCrossfadeBackdrop( [ show?.art, @@ -554,8 +558,19 @@ export function PlexShowView({ showRelated={showRelated} onToggleRelated={toggleRelated} hasRelated={d.related.length > 0} + extra={} /> } + aside={ + aside.column && ( + + ) + } > {show.title} {/* The same meta row as the media-info page: filtering values are chips, plain facts flat @@ -677,6 +692,13 @@ export function PlexShowView({ /> )}
+ {aside.shown.keywords && ( + + )} {/* Seasons — in a glassy panel like the movie info-page strips. */} diff --git a/frontend/src/components/ui/scrollFold.test.ts b/frontend/src/components/ui/scrollFold.test.ts new file mode 100644 index 00000000..24c83e6e --- /dev/null +++ b/frontend/src/components/ui/scrollFold.test.ts @@ -0,0 +1,24 @@ +// E31 (the owner's pick): the channel page's tall header folds to one row once the grid scrolls. The header +// is fixed, so folding gives the scroller its height back — these are the rules that keep that from +// flickering on a page barely taller than the window. +import { describe, expect, it } from "vitest"; +import { FOLD_AT, nextFolded } from "./scrollFold"; + +const SAVING = 200; + +describe("nextFolded", () => { + it("folds once the content scrolls past the fold point", () => { + expect(nextFolded(false, { top: FOLD_AT + 1, room: 5000 }, SAVING)).toBe(true); + expect(nextFolded(false, { top: FOLD_AT, room: 5000 }, SAVING)).toBe(false); + }); + + it("does not fold a page that could not stay scrolled once the header gave its height back", () => { + expect(nextFolded(false, { top: 100, room: SAVING + FOLD_AT }, SAVING)).toBe(false); + expect(nextFolded(false, { top: 100, room: SAVING + FOLD_AT + 1 }, SAVING)).toBe(true); + }); + + it("stays folded until the very top, not back at the fold point", () => { + expect(nextFolded(true, { top: 1, room: 5000 }, SAVING)).toBe(true); + expect(nextFolded(true, { top: 0, room: 5000 }, SAVING)).toBe(false); + }); +}); diff --git a/frontend/src/components/ui/scrollFold.ts b/frontend/src/components/ui/scrollFold.ts new file mode 100644 index 00000000..7a0aee54 --- /dev/null +++ b/frontend/src/components/ui/scrollFold.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState, type RefObject } from "react"; +import { CONTENT_ID } from "./landmarks"; + +// A tall page header that folds to one row once the page's content scrolls (E31, the owner's pick for the +// channel page: the banner and the identity block took ~40% of a 133% screen before the first video). +// +// The header is FIXED (it sits in the shell's toolbar band, outside the scroller), so folding it makes the +// scroller taller, which shortens how far the content can scroll. Two rules keep that from flickering: +// - fold only when the page can still scroll past the fold point once the header has given its height +// back (`room - saving > FOLD_AT`): a page barely taller than the window would otherwise fold, lose its +// scroll, land at the top, unfold, and so on, with every wheel tick; +// - unfold only at the very top, not at the fold point: between the two the header keeps whatever it was. +export const FOLD_AT = 32; + +export interface ScrollPosition { + // The scroller's scrollTop. + top: number; + // How far it can scroll at all: scrollHeight − clientHeight. + room: number; +} + +// `saving` = the height the header gives back when it folds (measured while it is unfolded). +export function nextFolded( + folded: boolean, + { top, room }: ScrollPosition, + saving: number, +): boolean { + if (folded) return top > 0; + return top > FOLD_AT && room - saving > FOLD_AT; +} + +// Whether the header is folded, following the page's one scroller (PageScroller's
). `foldable` is +// the part of the header that folds away; its height, read while it is shown, is the `saving` above. +export function useScrollFold(foldable: RefObject): boolean { + const [folded, setFolded] = useState(false); + const saving = useRef(0); + useEffect(() => { + const update = (el: HTMLElement) => { + if (foldable.current) saving.current = foldable.current.offsetHeight; + setFolded((was) => + nextFolded( + was, + { top: el.scrollTop, room: el.scrollHeight - el.clientHeight }, + saving.current, + ), + ); + }; + // Scroll events do not bubble but they do capture, so one document listener sees the scroller even + // when it mounts after this page (as BackToTop does). A Back that restores the position scrolls it too. + const onScroll = (e: Event) => { + const el = e.target; + if (el instanceof HTMLElement && el.id === CONTENT_ID) update(el); + }; + document.addEventListener("scroll", onScroll, { passive: true, capture: true }); + const main = document.getElementById(CONTENT_ID); + if (main) update(main); + return () => document.removeEventListener("scroll", onScroll, { capture: true }); + }, [foldable]); + return folded; +} diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index 164b7ccf..b0b15f88 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -60,7 +60,8 @@ function loadYouTubeApi(): Promise { /** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created * per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS * the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag. - * Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */ + * Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area — + * except over a `[data-wheel-scrolls]` panel that can still scroll that way. */ export function useYtVolume({ playerRef, dialogRef, @@ -137,6 +138,17 @@ export function useYtVolume({ const onWheelSpin = useEffectEvent((e: WheelEvent) => { // Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. if (e.ctrlKey || e.metaKey) return; + // Over a panel that can still scroll that way, the wheel scrolls it: the wide player's side panel + // holds the description, which a wheel spent on the volume could never be read past (E31). + const panel = + e.target instanceof Element ? e.target.closest("[data-wheel-scrolls]") : null; + if ( + panel && + (e.deltaY < 0 + ? panel.scrollTop > 0 + : panel.scrollTop + panel.clientHeight < panel.scrollHeight - 1) // -1: sub-pixel heights + ) + return; e.preventDefault(); focusModal(); // re-grab focus if it had slipped into the native controls nudgeVolume(e.deltaY < 0 ? 5 : -5); diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index c4d2e692..c9a894d9 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -1,7 +1,7 @@ import { useLayoutEffect } from "react"; import { usePref, useSavePref } from "./usePref"; import type { TFunction } from "i18next"; -import type { PlexCard } from "./api"; +import type { PlexCard, PlexTmdb } from "./api"; // The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display // prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so @@ -31,6 +31,56 @@ export function useDetailPrefs() { }; } +// What a detail page's facts are read from: a movie's item detail and a show's detail both carry these. +export interface DetailFacts { + year?: number | null; + duration_seconds?: number | null; + content_rating?: string | null; + studio?: string | null; + tmdb?: PlexTmdb | null; +} + +export type AsideSection = "facts" | "keywords" | "crew"; + +// The detail pages' second column (plexDetailAside, E31): which of its three sections — the TMDB facts, +// keywords and key crew — have something to show, which the reader keeps on (three per-user prefs, +// default ON, the same on the movie and the show page), and the toggles' flips. +export function useDetailAside(f: DetailFacts) { + const [facts, setFacts] = usePref("plexInfoFacts", true); + const [keywords, setKeywords] = usePref("plexInfoKeywords", true); + const [crew, setCrew] = usePref("plexInfoCrew", true); + const has: Record = { + facts: + f.year != null || + !!f.duration_seconds || + !!f.content_rating || + !!f.tmdb?.budget || + !!f.tmdb?.revenue || + !!f.tmdb?.studios?.length || + !!f.studio || + !!f.tmdb?.production_countries?.length, + keywords: (f.tmdb?.keywords?.length ?? 0) > 0, + crew: (f.tmdb?.crew?.length ?? 0) > 0, + }; + const on: Record = { facts, keywords, crew }; + const shown: Record = { + facts: facts && has.facts, + keywords: keywords && has.keywords, + crew: crew && has.crew, + }; + const set = { facts: setFacts, keywords: setKeywords, crew: setCrew }; + return { + has, + on, + shown, + // Whether the hero's second column has anything (the keywords sit under the text). + column: shown.facts || shown.crew, + toggle: (k: AsideSection) => set[k](!on[k]), + }; +} + +export type DetailAsideState = ReturnType; + // A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only // frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the // crossfade even on a lone movie with no collection. From 20010178b0ebb3ce08fb5eeba75cc90e4778f622 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 02:37:20 +0200 Subject: [PATCH 10/31] fix(ui): the Plex detail poster keeps its 2:3, and a long crew shows its first names The hero's grid row is as tall as the crew column beside the text, and the poster, a flex child, stretched to it: a 2:3 picture drawn ~175x590 and cropped. It now aligns to the top, and a wide hero gives the movie and show poster a bigger rung (w-56). The crew, the hero's tallest part, shows its first 6 names and a 'Show N more' toggle; a list of 8 or fewer shows whole. --- docs/DESIGN.md | 1 + frontend/src/components/PlexInfo.dom.test.tsx | 50 +++++++++++++++++++ frontend/src/components/PlexInfo.tsx | 3 +- frontend/src/components/plexDetailAside.tsx | 26 +++++++++- frontend/src/components/plexDetailUi.tsx | 3 +- frontend/src/i18n/locales/en/plex.json | 3 ++ frontend/src/i18n/locales/hu/plex.json | 3 ++ frontend/src/lib/plexDetail.ts | 5 ++ 8 files changed, 90 insertions(+), 4 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbc82c21..606120b0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -701,3 +701,4 @@ of a full-width panel and nothing on the right. | 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page | | 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site | | 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it | +| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height | diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 90b958bf..737e7f4d 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import PlexInfo from "./PlexInfo"; +import { CREW_SHOWN } from "./plexDetailAside"; import type { PlexItemDetail } from "../lib/api"; afterEach(cleanup); @@ -250,6 +251,55 @@ describe("PlexInfo — the hero's second column (page)", () => { expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull(); expect(follows(heading("Test Movie"), kw)).toBe(true); }); + + // E31: the hero's grid row is as tall as its taller column — the crew, beside the text — and the poster, + // a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio. + it("keeps the poster at its own height, not stretched to the row", () => { + wrap(); + const poster = document.querySelector(`img[src="${detail.thumb}"]`)!; + expect(poster.className).toContain("aspect-2/3"); + expect(poster.className.split(/\s+/)).toContain("self-start"); + }); + + // E31 (the owner's call): twelve names ran the column ~170px past the text. The first CREW_SHOWN show, + // the rest behind "Show N more"; a list barely longer shows whole. + it("shows the first crew names and opens the rest on demand", () => { + const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" })); + wrap( + , + ); + const aside = screen.getByTestId("detail-aside"); + expect(aside.textContent).toContain(`Person ${CREW_SHOWN - 1}`); + expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`); + const more = screen.getByRole("button", { name: "plex.info.crewMore" }); + expect(more.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(more); + expect(aside.textContent).toContain("Person 11"); + const less = screen.getByRole("button", { name: "plex.info.crewLess" }); + expect(less.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(less); + expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`); + }); + + it("shows a crew only a little longer than that whole, with no toggle", () => { + const crew = Array.from({ length: CREW_SHOWN + 2 }, (_, i) => ({ + name: `Person ${i}`, + job: "Producer", + })); + wrap( + , + ); + expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`); + expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull(); + }); }); // E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 1895f1f6..7d9d57f6 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -9,6 +9,7 @@ import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; import { + DETAIL_POSTER, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailAside, @@ -366,7 +367,7 @@ export default function PlexInfo({
diff --git a/frontend/src/components/plexDetailAside.tsx b/frontend/src/components/plexDetailAside.tsx index 977dbc38..3c312a76 100644 --- a/frontend/src/components/plexDetailAside.tsx +++ b/frontend/src/components/plexDetailAside.tsx @@ -1,10 +1,11 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { PlexFilters } from "../lib/api"; import { formatMoney, formatRuntime } from "../lib/format"; import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail"; import { regionName, vocab } from "../lib/vocab"; import { Filterable, PrefToggle } from "./plexDetailUi"; +import { Button } from "./ui/Button"; import { Pressable } from "./ui/Pressable"; // The detail pages' second column (E31, the owner's pick "A"): the TMDB facts and key crew of a movie or @@ -180,6 +181,12 @@ export function DetailKeywords({ ); } +// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the +// text is the hero's tallest part, and twelve names ran it ~170px past the text (E31, the owner's call). +// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room. +export const CREW_SHOWN = 6; +const CREW_FOLD_OVER = CREW_SHOWN + 2; + // The TMDB key crew (E5 S5) — Director / Writer / Producer / …, each opening their person page. It // complements the hero's Director line (which comes from Plex directly). function CrewSection({ @@ -190,12 +197,15 @@ function CrewSection({ onOpenPerson?: (name: string) => void; }) { const { t } = useTranslation(); + const [all, setAll] = useState(false); + const folds = crew.length > CREW_FOLD_OVER; + const shown = folds && !all ? crew.slice(0, CREW_SHOWN) : crew; return (

{t("plex.info.crew")}

{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
- {crew.map((c, i) => ( + {shown.map((c, i) => (
{onOpenPerson ? ( ))}
+ {folds && ( + + )}
); } diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index bab037fc..1eb6b547 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -9,6 +9,7 @@ import { type LucideIcon, } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; +import { DETAIL_POSTER } from "../lib/plexDetail"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; @@ -239,7 +240,7 @@ export function DetailHero({ posterDisabled, showPlayOverlay, onPosterClick, - posterSizeClass = "w-32 sm:w-44", + posterSizeClass = DETAIL_POSTER, customizeMenu, aside, children, diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 6df97c52..721b4ac9 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -277,6 +277,9 @@ "audience": "Audience", "keywords": "Keywords", "crew": "Crew", + "crewMore_one": "Show {{count}} more", + "crewMore_other": "Show {{count}} more", + "crewLess": "Show less", "filterValue": "Filter by “{{name}}”", "facts": { "title": "Details", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 20165d44..4ada8f17 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -277,6 +277,9 @@ "audience": "Közönség", "keywords": "Kulcsszavak", "crew": "Stáb", + "crewMore_one": "Még {{count}} mutatása", + "crewMore_other": "Még {{count}} mutatása", + "crewLess": "Kevesebb", "filterValue": "Szűrés erre: „{{name}}”", "facts": { "title": "Részletek", diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index c9a894d9..3834d464 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -42,6 +42,11 @@ export interface DetailFacts { export type AsideSection = "facts" | "keywords" | "crew"; +// The movie and the show page's poster. It keeps its 2:3 (self-start, never stretched to the row: the +// second column beside the text runs taller than the poster), and a wide hero (the @container +// panel at 64rem+) gives it a bigger rung, so it is not a stamp beside a full text column (E31). +export const DETAIL_POSTER = "w-32 sm:w-44 @5xl:w-56"; + // The detail pages' second column (plexDetailAside, E31): which of its three sections — the TMDB facts, // keywords and key crew — have something to show, which the reader keeps on (three per-user prefs, // default ON, the same on the movie and the show page), and the toggles' flips. From e995c664c647f2c5a8a24c17103f362f3f7f5060 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 03:16:37 +0200 Subject: [PATCH 11/31] perf(db): the channel manager and the feed sidebar read an index, not the video heap GET /api/channels summed every subscribed channel's videos by reading each video's heap page (681 MB on prod): 51-61 ms warm, but 2.25 s on a first open and 6.4 s under EXPLAIN once the table had left memory. /feed/summary and /feed/years did the same (~140 ms warm, ~800 ms cold). Migration 0095 replaces ix_videos_channel_pub_desc with the same key INCLUDING the columns those queries read, so they are Index Only Scans over a 25 MB index. A test holds the queries and the index's column list together. The admin Configuration page did a db.get per setting (two for an unset one): it now reads every override in one query (~40 ms of ~55 on prod). --- .../0095_videos_channel_cover_index.py | 47 +++++++++++ backend/app/models.py | 10 ++- backend/app/routes/channels.py | 35 ++++---- backend/app/sysconfig.py | 22 +++-- backend/tests/test_channel_list_index_only.py | 83 +++++++++++++++++++ .../tests/test_sysconfig_describe_queries.py | 39 +++++++++ backend/tests/test_sysconfig_registry.py | 10 ++- 7 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 backend/alembic/versions/0095_videos_channel_cover_index.py create mode 100644 backend/tests/test_channel_list_index_only.py create mode 100644 backend/tests/test_sysconfig_describe_queries.py diff --git a/backend/alembic/versions/0095_videos_channel_cover_index.py b/backend/alembic/versions/0095_videos_channel_cover_index.py new file mode 100644 index 00000000..96ef400b --- /dev/null +++ b/backend/alembic/versions/0095_videos_channel_cover_index.py @@ -0,0 +1,47 @@ +"""videos: the per-channel index covers what the channel manager and the feed sidebar read (E31 S4d) + +The channel manager (`GET /api/channels`) sums each subscribed channel's videos: count, last upload, total +duration. The feed sidebar (`/feed/summary`, `/feed/years`) counts the reader's videos per channel and per +year under the default filters: available, not via explore, not a short, not live. Every one of them found +its rows through `ix_videos_channel_pub_desc` (channel_id, published_at DESC NULLS LAST, id) and then read +each video's heap page for the rest: ~270k rows at ~2.5 KB each, 681 MB on prod. That stays in cache only +while nothing else needs the memory. Measured on prod (2026-10-02): `/api/channels` 51–61 ms warm, but +2.25 s and 1.96 s on a first open in the browser and 6.4 s under EXPLAIN with 30k blocks read from disk; +`/feed/summary` and `/feed/years` ~140 ms warm, ~800 ms cold. + +The same key with those columns INCLUDEd makes them Index Only Scans. On a prod-copy localdev, after a +VACUUM, the manager's sums read 0 heap pages and the summary reads the 25 MB index instead of the heap. +The visibility map is what lets them skip the heap; prod's autovacuum kept it complete (relallvisible == +relpages). The key columns are unchanged, so every reader of the old index (the per-channel feed, the FK +cascade, the sync upsert, channel detail) rides this one. The old index is dropped. +tests/test_channel_list_index_only.py holds the queries and this column list together. + +CONCURRENTLY (autocommit_block) builds it without blocking a live prod. IF (NOT) EXISTS keeps the drift +test's downgrade → re-upgrade round trip idempotent. +""" + +from alembic import op + +revision: str = "0095_videos_channel_cover_index" +down_revision: str | None = "0094_plex_genres_hu_everywhere" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_channel_pub_cover " + "ON videos (channel_id, published_at DESC NULLS LAST, id) " + "INCLUDE (duration_seconds, unavailable_since, via_explore, is_short, live_status)" + ) + op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_channel_pub_desc") + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_channel_pub_desc " + "ON videos (channel_id, published_at DESC NULLS LAST, id ASC)" + ) + op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_channel_pub_cover") diff --git a/backend/app/models.py b/backend/app/models.py index 91b268c0..e7da5d8d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -330,10 +330,12 @@ class Video(Base, TimestampMixin): __tablename__ = "videos" id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id - # No standalone channel_id index: the composite `ix_videos_channel_pub_desc` - # (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it - # serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed - # sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`. + # No standalone channel_id index: the composite `ix_videos_channel_pub_cover` + # (channel_id, published_at DESC NULLS LAST, id) from migration 0095 leads with channel_id, so it + # serves every channel_id lookup (FK cascade, sync upsert, channel-detail) and the per-channel feed + # sort. Its INCLUDEd columns make the channel manager's sums and the feed sidebar's counts Index Only + # Scans (no heap): a column those queries start reading belongs in it (test_channel_list_index_only). + # Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`. channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles) original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 9c9c3a52..3f63dfa9 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends from pydantic import BaseModel -from sqlalchemy import func, select, update +from sqlalchemy import Select, func, select, update from sqlalchemy.orm import Session from app.collation import collated, sorted_words @@ -40,6 +40,22 @@ router = APIRouter(prefix="/api/channels", tags=["channels"]) log = logs.get_logger(__name__) +def channel_aggregates(channel_ids: list[str]) -> Select: + """Per-channel stored count, last upload and total duration, in one grouped pass. Every column it + reads is in `ix_videos_channel_pub_cover` (migration 0095), so Postgres answers it from the index + alone; a test holds that (test_channel_list_index_only).""" + return ( + select( + Video.channel_id, + func.count(Video.id), + func.max(Video.published_at), + func.coalesce(func.sum(Video.duration_seconds), 0), + ) + .where(Video.channel_id.in_(channel_ids)) + .group_by(Video.channel_id) + ) + + @router.get("") def list_channels(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: rows = db.execute( @@ -49,24 +65,15 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_ .order_by(Subscription.priority.desc(), collated(Channel.title, lang_for_user(user))) ).all() - # Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last - # upload, total duration. Cheap — a single grouped scan, so computed on read rather than - # denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns). + # Per-channel aggregates over the shared catalog (channel_aggregates), computed on read rather than + # denormalised: an Index Only Scan over a ~20 MB index, where it used to read every video's heap page + # (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory, E31 S4d). # (The Normal/Shorts/Live breakdown moved to the channel-detail page in R14, so it is no longer # computed here — the manager table dropped its N/S/L column.) channel_ids = [c.id for _, c in rows] agg: dict[str, dict] = {} if channel_ids: - for cid, n, last_up, total_dur in db.execute( - select( - Video.channel_id, - func.count(Video.id), - func.max(Video.published_at), - func.coalesce(func.sum(Video.duration_seconds), 0), - ) - .where(Video.channel_id.in_(channel_ids)) - .group_by(Video.channel_id) - ).all(): + for cid, n, last_up, total_dur in db.execute(channel_aggregates(channel_ids)).all(): agg[cid] = { "stored": n, "last_video_at": iso(last_up), diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index a1ddc43b..cef6ee07 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -10,6 +10,7 @@ stored in the DB and stay env-only). from dataclasses import dataclass +from sqlalchemy import select from sqlalchemy.orm import Session from app import logs, security @@ -307,7 +308,11 @@ def _raw_override(db: Session, key: str) -> str | None: per key so the operator gets a real signal without a per-request traceback. Recovery is documented in docs/self-hosting.md: the key must never change once secrets are stored. (B-core-2) """ - row = db.get(SystemConfig, key) + return _raw_of(db.get(SystemConfig, key), key) + + +def _raw_of(row: SystemConfig | None, key: str) -> str | None: + """`_raw_override` over a row already read (describe() reads them all in one query).""" if row is None or row.value is None: return None if not row.encrypted: @@ -327,8 +332,11 @@ def _raw_override(db: Session, key: str) -> str | None: def get(db: Session, key: str): """Effective value: the DB override (typed) if set, else the env/config default.""" - s = _BY_KEY[key] - raw = _raw_override(db, key) + return _effective(_BY_KEY[key], _raw_override(db, key)) + + +def _effective(s: ConfigSpec, raw: str | None): + """`get` over an override already read.""" if raw is None: return _default(s) if raw == "" and not s.allow_empty: @@ -420,8 +428,12 @@ def describe(db: Session) -> dict: """Admin-UI metadata + current values, grouped. Secret values are never echoed — only whether an override is set and whether an env default exists.""" groups: dict[str, list[dict]] = {} + # Every override in one query: a db.get per key was one round trip each (two for a key with no + # row, which the session cannot cache), ~40 ms of the page's ~55 on prod (E31 S4d). + rows = {r.key: r for r in db.scalars(select(SystemConfig))} for s in SPECS: - is_set = db.get(SystemConfig, s.key) is not None + row = rows.get(s.key) + is_set = row is not None item: dict = { "key": s.key, "type": s.type, @@ -436,7 +448,7 @@ def describe(db: Session) -> dict: if s.secret: item["default_is_set"] = bool(_default(s)) else: - item["value"] = get(db, s.key) + item["value"] = _effective(s, _raw_of(row, s.key)) item["default"] = _default(s) groups.setdefault(s.group, []).append(item) sections = [ diff --git a/backend/tests/test_channel_list_index_only.py b/backend/tests/test_channel_list_index_only.py new file mode 100644 index 00000000..cdcbd72b --- /dev/null +++ b/backend/tests/test_channel_list_index_only.py @@ -0,0 +1,83 @@ +"""The channel manager's sums and the feed sidebar's counts read the index alone (E31 S4d). + +`GET /api/channels` counts each subscribed channel's videos and sums their durations; `/feed/summary` +and `/feed/years` count the reader's videos per channel and per year under the default filters. They +used to read every video's heap page for that (681 MB on prod), a 2–6 s first open once something else +had pushed the table out of memory. Migration 0095 makes `ix_videos_channel_pub_cover` carry every +column those queries read, so Postgres answers them with Index Only Scans. This test holds the queries +and the index together: a column added to one and not the other fails it. DB lane. +""" + +import re + +from sqlalchemy import text +from sqlalchemy.dialects import postgresql + +from app.feedquery import filtered_query +from app.models import Video +from app.routes.channels import channel_aggregates + +INDEX = "ix_videos_channel_pub_cover" + +# The feed's own defaults (routes/feed.py _feed_params): what the sidebar asks on a plain open. +FEED_DEFAULTS = { + "tags": [], + "tag_modes": {"language": "or", "topic": "or", "other": "or"}, + "untagged": False, + "channel_ids": [], + "q": None, + "min_duration": None, + "max_duration": None, + "max_age_days": None, + "published_after": None, + "published_before": None, + "show_normal": True, + "include_shorts": False, + "include_live": False, + "show": "unwatched", + "scope": "my", + "library_source": "organic", +} + + +def _index_columns(db) -> list[str]: + # Key columns first, then the INCLUDE ones, in the index's own order. + return list( + db.execute( + text( + "SELECT a.attname FROM pg_index i " + "JOIN pg_class c ON c.oid = i.indexrelid " + "CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, n) " + "JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum " + "WHERE c.relname = :name ORDER BY k.n" + ), + {"name": INDEX}, + ).scalars() + ) + + +def _video_columns(stmt) -> set[str]: + # The columns of `videos` the statement as SENT reads: the compiled SQL, not the expression tree, + # which still carries the projection a with_only_columns replaced. + sql = str(stmt.compile(dialect=postgresql.dialect())) + return set(re.findall(r"\bvideos\.(\w+)", sql)) + + +def _assert_covered(db, read: set[str]) -> None: + held = _index_columns(db) + assert held, f"{INDEX} is missing" + # It leads with channel_id, so it also serves every per-channel lookup the old index did. + assert held[0] == "channel_id" + assert read <= set(held), f"read but not in {INDEX}: {sorted(read - set(held))}" + + +def test_the_channel_sums_read_only_columns_the_index_holds(db): + _assert_covered(db, _video_columns(channel_aggregates(["UC_x"]))) + + +def test_the_feed_sidebar_counts_read_only_columns_the_index_holds(db, make_user): + # feed_summary groups the default filter by channel, feed_years by publish date. + base, _rank = filtered_query(db, make_user(), **FEED_DEFAULTS) + _assert_covered( + db, _video_columns(base.with_only_columns(Video.channel_id, Video.published_at)) + ) diff --git a/backend/tests/test_sysconfig_describe_queries.py b/backend/tests/test_sysconfig_describe_queries.py new file mode 100644 index 00000000..9c1aff47 --- /dev/null +++ b/backend/tests/test_sysconfig_describe_queries.py @@ -0,0 +1,39 @@ +"""The admin Configuration page reads every override in one query (E31 S4d). + +`sysconfig.describe()` did a `db.get` per key to tell whether an override was set, and `get()` did one +more for its value: ~55 keys, and two round trips for a key with no row, since the session cannot cache +an absence. That was ~40 ms of the page's ~55 on prod. It now reads the table once. DB lane. +""" + +from sqlalchemy import event + +from app import sysconfig + + +def _statements(db, fn) -> int: + seen: list[str] = [] + engine = db.get_bind() + listener = lambda *a, **k: seen.append(a[2]) # noqa: E731 — (conn, cursor, statement, …) + event.listen(engine, "before_cursor_execute", listener) + try: + fn() + finally: + event.remove(engine, "before_cursor_execute", listener) + return len(seen) + + +def test_describe_reads_the_overrides_once(db): + sysconfig.set_value(db, "quota_daily_budget", 5000) + db.commit() + assert _statements(db, lambda: sysconfig.describe(db)) == 1 + + +def test_describe_shows_the_stored_value_and_the_default(db): + sysconfig.set_value(db, "quota_daily_budget", 5000) + db.commit() + items = {i["key"]: i for g in sysconfig.describe(db)["groups"].values() for i in g} + assert items["quota_daily_budget"]["is_set"] is True + assert items["quota_daily_budget"]["value"] == 5000 + other = next(i for k, i in items.items() if k != "quota_daily_budget" and not i["secret"]) + assert other["is_set"] is False + assert other["value"] == other["default"] diff --git a/backend/tests/test_sysconfig_registry.py b/backend/tests/test_sysconfig_registry.py index d933ec30..4190f71e 100644 --- a/backend/tests/test_sysconfig_registry.py +++ b/backend/tests/test_sysconfig_registry.py @@ -67,19 +67,23 @@ def test_the_env_accepts_any_letter_case_and_rejects_an_unknown_value(): class _Row: - def __init__(self, value): - self.value, self.encrypted = value, False + def __init__(self, key, value): + self.key, self.value, self.encrypted = key, value, False class _FakeDb: """Just enough of a Session for sysconfig's reads: one stored override row per key.""" def __init__(self, **rows): - self.rows = {k: _Row(v) for k, v in rows.items()} + self.rows = {k: _Row(k, v) for k, v in rows.items()} def get(self, _model, key): return self.rows.get(key) + def scalars(self, _stmt): + # describe() reads every override at once (E31 S4d). + return list(self.rows.values()) + def test_a_stored_value_reads_back_canonical_and_a_stale_one_falls_back_to_the_default(): assert sysconfig.get_str(_FakeDb(plex_transcode_hw="OFF"), "plex_transcode_hw") == "off" From b4ef07d28e64233f30ec5233cc45d2418566aca8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 03:16:37 +0200 Subject: [PATCH 12/31] perf(ui): a long table page paints its first rows at once and the rest in steps The channel manager's All page (~400 rows) blocked one frame for 0.7-0.9 s on prod to mount, and every sort and search key re-rendered every row (300-400 ms). No single cell is to blame, measured column by column. DataTable now mounts the first 30 rows at once and 40 more per background transition; a new page, size, sort, filter or caller-side search (narrowedBy) starts over from the first rows. A row is its own component, so the compiler keeps the rows a step already holds. --- frontend/src/components/ChannelDiscovery.tsx | 1 + frontend/src/components/Channels.tsx | 1 + .../ui/DataTable.progressive.dom.test.tsx | 103 ++++++++++++++++++ frontend/src/components/ui/DataTable.tsx | 79 ++++++++++---- frontend/src/components/ui/progressiveRows.ts | 33 ++++++ 5 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/ui/DataTable.progressive.dom.test.tsx create mode 100644 frontend/src/components/ui/progressiveRows.ts diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 3e0896f8..96164322 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -242,6 +242,7 @@ export default function ChannelDiscovery({ onSortChange={setSort} emptyText={t("channels.discovery.empty")} keyboardNav + narrowedBy={q} /> ) : ( ) : ( <> diff --git a/frontend/src/components/ui/DataTable.progressive.dom.test.tsx b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx new file mode 100644 index 00000000..db15fa1c --- /dev/null +++ b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +// +// E31 S4d: the channel manager's "All" page (~400 rows) blocked one frame for 0.7–0.9 s on prod to +// mount, and every key in its search re-rendered every row. A long page now mounts its first rows at +// once and the rest in small background steps (progressiveRows), and a step leaves the rows it holds +// alone (a row is its own component, whose cells the React Compiler caches). +// +// These tests drive React without act(): act flushes the background steps along with the first render, +// and the first render on its own is the property under test. +import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from "vitest"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { waitFor } from "@testing-library/react"; +import DataTable, { type Column } from "./DataTable"; +import { FIRST_ROWS } from "./progressiveRows"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; +}); + +const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let wasAct: boolean | undefined; +beforeAll(() => { + wasAct = g.IS_REACT_ACT_ENVIRONMENT; + g.IS_REACT_ACT_ENVIRONMENT = false; +}); +afterAll(() => { + g.IS_REACT_ACT_ENVIRONMENT = wasAct; +}); + +type Row = { id: string; name: string }; +const many = (n: number, prefix = "r"): Row[] => + Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, name: `${prefix} ${i}` })); + +let root: Root | null = null; +let host: HTMLElement | null = null; +afterEach(() => { + root?.unmount(); + host?.remove(); + root = null; + host = null; +}); + +const bodyRows = () => host!.querySelectorAll("tbody tr").length; + +// Mounts synchronously, so what the DOM holds right after is the first render alone. +function mount(rows: Row[], columns: Column[], narrowedBy = "") { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + const draw = (r: Row[], n: string) => + flushSync(() => + root!.render( + x.id} pageSize={0} narrowedBy={n} />, + ), + ); + draw(rows, narrowedBy); + return draw; +} + +const plain: Column[] = [{ key: "name", header: "Name", render: (r) => r.name }]; + +describe("DataTable — a long page mounts in steps", () => { + it("paints its first rows at once and the rest after", async () => { + mount(many(200), plain); + expect(bodyRows()).toBe(FIRST_ROWS); + await waitFor(() => expect(bodyRows()).toBe(200)); + }); + + it("does not re-render the rows it already holds as it grows", async () => { + const renders = new Map(); + const counted: Column[] = [ + { + key: "name", + header: "Name", + render: (r) => { + renders.set(r.id, (renders.get(r.id) ?? 0) + 1); + return r.name; + }, + }, + ]; + mount(many(200), counted); + await waitFor(() => expect(bodyRows()).toBe(200)); + // The first row was drawn by the first render, not again by each step after it. + expect(renders.get("r0")).toBe(1); + }); + + it("starts over from the first rows when the caller narrows them, not on new data", async () => { + const draw = mount(many(200), plain, ""); + await waitFor(() => expect(bodyRows()).toBe(200)); + // New data for the same view (a refetch): the page keeps what it shows. + draw(many(200), ""); + expect(bodyRows()).toBe(200); + // A search the caller applied: the urgent render is small again. + draw(many(150, "s"), "s"); + expect(bodyRows()).toBe(FIRST_ROWS); + await waitFor(() => expect(bodyRows()).toBe(150)); + }); +}); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index ce2d6296..db8c02a9 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -11,6 +11,7 @@ import { navItemAttrs, useListNav } from "../../lib/listNav"; import { useMediaQuery } from "../../lib/useMediaQuery"; import { SURFACE } from "./surfaces"; import { HiddenFilterCue } from "./HiddenFilterCue"; +import { useProgressiveRows } from "./progressiveRows"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps @@ -48,6 +49,45 @@ function loadPersist(key?: string): Persisted { } } +interface TableRowProps { + row: T; + index: number; + columns: Column[]; + rowProps?: (row: T) => Record; + rowClassName?: (row: T) => string; + keyboardNav: boolean; +} + +// A row is its own component so the React Compiler caches its cells by (row, columns, …): a long page +// grows in steps (progressiveRows), and a step must not draw the rows it already holds again. Inline in +// the body's map they all were, and the steps grew from ~70 to ~200 ms (dev build) as the page filled. +function TableRow({ + row, + index, + columns, + rowProps, + rowClassName, + keyboardNav, +}: TableRowProps) { + return ( + + {columns.map((col) => ( + + {col.render(row)} + + ))} + + ); +} + export default function DataTable({ rows, columns, @@ -72,6 +112,7 @@ export default function DataTable({ keyboardNav = false, reveal, surfacedFilters, + narrowedBy = "", }: { rows: T[]; columns: Column[]; @@ -131,6 +172,10 @@ export default function DataTable({ // Filter keys the caller shows somewhere besides their column (the channel manager's tag chips): // the hidden-filter cue leaves those out, it would name them twice. surfacedFilters?: string[]; + // What the caller narrowed `rows` by before passing them in (the channel manager's name search lives + // in the shared top bar): a change starts a long page over from its first rows, as the table's own + // sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page. + narrowedBy?: string; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -229,6 +274,11 @@ export default function DataTable({ if (at >= 0 && !allRows) setPage(Math.floor(at / size)); } const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size); + // A long page ("All", 100) mounts its first rows at once and the rest in the background. + const shown = useProgressiveRows( + paged, + `${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`, + ); function toggleSort(key: string) { const next = cycleSort(sort, key); @@ -327,25 +377,16 @@ export default function DataTable({ onFilter={applyFilter} /> - {paged.map((row, i) => ( - ( + - {columns.map((col) => ( - - {col.render(row)} - - ))} - + row={row} + index={i} + columns={columns} + rowProps={rowProps} + rowClassName={rowClassName} + keyboardNav={keyboardNav} + /> ))} @@ -356,7 +397,7 @@ export default function DataTable({ <> {cue("cards")}
- {paged.map((row, i) => ( + {shown.map((row, i) => (
(rows: T[], view: string): T[] { + const [seen, setSeen] = useState(view); + const [limit, setLimit] = useState(FIRST_ROWS); + if (seen !== view) { + setSeen(view); + setLimit(FIRST_ROWS); + } + const more = rows.length > limit; + useEffect(() => { + if (more) startTransition(() => setLimit(limit + STEP_ROWS)); + }, [more, limit]); + return more ? rows.slice(0, limit) : rows; +} From 540822d2b00128f2c68d5c604f15cca403d29f77 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 20:46:30 +0200 Subject: [PATCH 13/31] perf(ui): a next-page fetch joins the one in flight instead of sending the cursor again react-query's fetchNextPage cancels a next page still loading and sends it again by default. Our requests ignore the abort signal, so the server ran both: the feed's read-ahead effect re-runs on scroll frames before isFetchingNextPage reaches it, and on prod every feed page went out 2-3 times (a 0.8 s title sort queued to 2.3 s). Every call now passes NEXT_PAGE ({cancelRefetch: false}), lint-enforced, with a contract test. --- frontend/eslint.config.js | 9 ++++++ frontend/src/components/AdminLogs.tsx | 5 ++-- frontend/src/components/Feed.tsx | 3 +- frontend/src/components/PlexBrowse.tsx | 4 ++- frontend/src/components/VirtualFeed.tsx | 3 +- frontend/src/components/ui/VirtualGrid.tsx | 6 ++-- frontend/src/lib/nextPage.contract.test.ts | 31 +++++++++++++++++++ frontend/src/lib/nextPage.dom.test.ts | 35 ++++++++++++++++++++++ frontend/src/lib/nextPage.ts | 9 ++++++ 9 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/nextPage.contract.test.ts create mode 100644 frontend/src/lib/nextPage.dom.test.ts create mode 100644 frontend/src/lib/nextPage.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4500b12e..3d1b998a 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -547,6 +547,15 @@ export default tseslint.config( // lib/*.contract.test.ts). "no-restricted-syntax": [ "error", + // A next-page fetch called bare CANCELS the one in flight and sends the same cursor again; + // our requests ignore the abort, so the server ran both (E31 S4e: every feed page 2–3×). + // lib/nextPage.ts NEXT_PAGE makes the second call join the first. + { + selector: + "CallExpression[arguments.length=0]:matches([callee.name='fetchNextPage'], [callee.property.name='fetchNextPage'])", + message: + "fetchNextPage() cancels and re-sends a page already loading: call fetchNextPage(NEXT_PAGE) (lib/nextPage.ts).", + }, // `enabled:` sees only the native attribute; a FocusSafeButton that holds the focus is // disabled by aria-disabled, so a bare `enabled:hover:` lit it up on hover as if it worked. { diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index bfc0a8e0..656ce33f 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -13,6 +13,7 @@ import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api import { copyText } from "../lib/clipboard"; import { formatDateTimeSec } from "../lib/format"; import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history"; +import { NEXT_PAGE } from "../lib/nextPage"; import { LEVEL_GROUPS, type LevelGroup } from "../lib/log"; import { DEFAULT_LOGS_STATE, @@ -156,7 +157,7 @@ export default function AdminLogs() { ? "countsFailed" : null; const retryFailed = () => { - if (logs.isFetchNextPageError) void logs.fetchNextPage(); + if (logs.isFetchNextPageError) void logs.fetchNextPage(NEXT_PAGE); else if (logs.isError) void logs.refetch(); if (facets.isError) void facets.refetch(); }; @@ -190,7 +191,7 @@ export default function AdminLogs() { const loadOlder = () => { setLive(false); - void logs.fetchNextPage(); + void logs.fetchNextPage(NEXT_PAGE); }; const copy = async () => { diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index a6fabbf5..3eb7d287 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -7,6 +7,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; +import { NEXT_PAGE } from "../lib/nextPage"; import { ArrowLeft, Columns2, @@ -556,7 +557,7 @@ export default function Feed({
+
+ ), +})); +vi.mock("./SelectedViewChip", () => ({ default: () => null })); +vi.mock("./ActiveFilterChips", () => ({ default: () => null })); + +import Feed from "./Feed"; +import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const FILTERS: FeedFilters = { + tags: [], + tagModes: DEFAULT_TAG_MODES, + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +describe("Feed — a next page that fails", () => { + it("stops the read-ahead, says so, and loads the page on a retry", async () => { + const page = (n: number, next: string | null) => ({ + items: [ + { id: `v${n}`, title: `v${n}`, channel_id: "UCx", published_at: "2026-01-01T00:00:00Z" }, + ], + next_cursor: next, + }); + let failNext = true; + const feed = vi + .spyOn(api, "feed") + .mockImplementation(((_f: FeedFilters, cursor: string | null) => + cursor === null + ? Promise.resolve(page(1, "c2")) + : failNext + ? Promise.reject(new Error("boom")) + : Promise.resolve(page(2, null))) as never); + vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 2, facets: {} } as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); + render( + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + /> + , + ); + await waitFor(() => expect(screen.getByTestId("has-next").textContent).toBe("true")); + + fireEvent.click(screen.getByText("read ahead")); + await waitFor(() => expect(screen.getByText("common.loadMoreFailed")).toBeTruthy()); + expect(screen.getByTestId("has-next").textContent).toBe("false"); // the read-ahead stands down + const calls = feed.mock.calls.length; + + failNext = false; + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + await waitFor(() => expect(feed.mock.calls.length).toBe(calls + 1)); + await waitFor(() => expect(screen.queryByText("common.loadMoreFailed")).toBeNull()); + }); +}); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 3f87416a..bd10d7e1 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -7,7 +7,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { NEXT_PAGE } from "../lib/nextPage"; +import { nextPageOnClick } from "../lib/nextPage"; import { ArrowLeft, Columns2, @@ -557,7 +557,9 @@ export default function Feed({
)}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 6b80d6df..1259f6b8 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -9,7 +9,7 @@ import { } from "@tanstack/react-query"; import { User, X } from "lucide-react"; import { qk } from "../lib/queryKeys"; -import { mayReadAhead, NEXT_PAGE } from "../lib/nextPage"; +import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../lib/nextPage"; import { healPlexField } from "../lib/plexSearch"; import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import type { SortState } from "../lib/columnSort"; @@ -317,7 +317,10 @@ export default function PlexBrowse() { // mount/unmount, so the observer always watches the live node; a plain ref would keep observing the // detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages). const [sentinelEl, setSentinelEl] = useState(null); - const { hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } = browseQ; + const { isFetching, isFetchingNextPage, isFetchNextPageError, fetchNextPage } = browseQ; + // A failed next page stops the read-ahead (the row at the bottom retries): the observer is rebuilt + // on every settle and would re-request it once a round-trip while the sentinel is in view. + const hasNextPage = browseQ.hasNextPage && !isFetchNextPageError; useEffect(() => { if (!sentinelEl) return; const io = new IntersectionObserver( @@ -753,6 +756,15 @@ export default function PlexBrowse() { {isFetchingNextPage && (

{t("plex.loading")}

)} + {isFetchNextPageError && !isFetchingNextPage && ( + void fetchNextPage(nextPageOnClick(isFetchingNextPage))} + > + {t("common.loadMoreFailed")} + + )}
); } diff --git a/frontend/src/components/mediaPicture.contract.test.ts b/frontend/src/components/mediaPicture.contract.test.ts index dc8755f1..385c2bcb 100644 --- a/frontend/src/components/mediaPicture.contract.test.ts +++ b/frontend/src/components/mediaPicture.contract.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import videoCard from "./VideoCard.tsx?raw"; import { + MEDIA_BAR, MEDIA_HOVER_LAYER, MEDIA_PICTURE_CONTAINER, MEDIA_RESUME_LAYER, @@ -24,6 +25,16 @@ describe("a media picture", () => { ["the Play", MEDIA_HOVER_LAYER], ["the Continue + Restart pair", MEDIA_RESUME_LAYER], ])("leaves the hover row its band when narrow: %s", (_, layer) => { - expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-10"); + expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-(--bar-room)"); + }); + + // E31 review 2: the lift applied to every narrow picture, and the feed's row and tile views (a + // 10rem thumbnail, no hover row) showed their Play 1.25rem above centre over an empty band. The band + // is now the row's own: set only by a box that holds one. + it("lifts it only where a hover row is there to clear", () => { + expect(MEDIA_BAR.split(" ")).toContain("media-bar"); + expect(MEDIA_PICTURE_CONTAINER.split(" ")).toContain("has-[.media-bar]:[--bar-room:2.5rem]"); + for (const layer of [MEDIA_HOVER_LAYER, MEDIA_RESUME_LAYER]) + expect(layer).not.toMatch(/pic:pb-\d/); }); }); diff --git a/frontend/src/components/ui/DataTable.progressive.dom.test.tsx b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx index 42942f37..a5dd2910 100644 --- a/frontend/src/components/ui/DataTable.progressive.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx @@ -124,4 +124,32 @@ describe("DataTable — a long page mounts in steps", () => { draw({ key: "r90", seq: 1 }); expect(host.querySelector("tbody")?.textContent).toContain("r 90"); }); + + // E31 review 2: the reveal floor held for the page's lifetime. Channels never clears its `follow`, so + // after one priority pick every later sort or search mounted every row up to that channel at once. + it("lets a revealed row go once the reader moves to another view", () => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + const rows = many(200); + const draw = (reveal: { key: string; seq: number } | null, narrowedBy: string) => + flushSync(() => + root!.render( + x.id} + pageSize={0} + reveal={reveal} + narrowedBy={narrowedBy} + />, + ), + ); + draw(null, ""); + const follow = { key: "r150", seq: 1 }; + draw(follow, ""); + expect(host.querySelector("tbody")?.textContent).toContain("r 150"); + draw(follow, "s"); // a search: a new view, the same (never cleared) reveal + expect(bodyRows()).toBe(FIRST_ROWS); + }); }); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index e60a75b2..7a2eab8c 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -267,19 +267,28 @@ export default function DataTable({ // Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame // that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a // stale reveal must not move a page nobody asked to move. + const viewOf = (p: number) => + `${p}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`; const [revealSeen, setRevealSeen] = useState(reveal?.seq); + // The view the reveal was made for: the floor below holds only there. A caller keeps its reveal + // after the focus (Channels never clears `follow`), and a floor that outlived its view mounted every + // row up to that channel on each later sort or search (E31 review 2). + const [revealView, setRevealView] = useState(null); if (reveal && reveal.seq !== revealSeen) { setRevealSeen(reveal.seq); const at = sorted.findIndex((row) => rowKey(row) === reveal.key); - if (at >= 0 && !allRows) setPage(Math.floor(at / size)); + const target = at >= 0 && !allRows ? Math.floor(at / size) : safePage; + if (target !== safePage) setPage(target); + setRevealView(viewOf(target)); } + const view = viewOf(safePage); const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size); // A long page ("All", 100) mounts its first rows at once and the rest in the background — but // always through the revealed row, which the caller focuses as soon as this frame commits. const shown = useProgressiveRows( paged, - `${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`, - reveal ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1, + view, + reveal && revealView === view ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1, ); function toggleSort(key: string) { diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx index 1de3c654..24675aad 100644 --- a/frontend/src/components/ui/ItemCount.dom.test.tsx +++ b/frontend/src/components/ui/ItemCount.dom.test.tsx @@ -70,4 +70,17 @@ describe("ItemCount", () => { expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true"); expect(screen.getByText("5 items").className).toContain("opacity-60"); }); + + // E31 review 2: the dim and aria-busy were the only stale cue, and a screen reader voices neither on + // plain text; a failed count looked like a first load. + it("tells a screen reader a stale count is updating", () => { + render(); + expect(text()).toBe("5 itemsupdating"); + }); + + it("says a count that failed, rather than looking like a first load", () => { + render(); + expect(text()).toBe("—Couldn't count the items"); + expect(screen.getByTestId("item-count").getAttribute("title")).toBe("Couldn't count the items"); + }); }); diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx index a7f0ee65..3a01acb6 100644 --- a/frontend/src/components/ui/ItemCount.tsx +++ b/frontend/src/components/ui/ItemCount.tsx @@ -14,15 +14,26 @@ const OVERFLOW_AT = 1_000_000; // ("999,999 items") and the content is centred within it. A count of a million or more collapses to // "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural) // so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's, -// kept while the new one loads (keepPreviousData) — dimmed and aria-busy, as AdminLogs dims its lines, -// so a broad search's slow count ("a": ~3 s) never reads as the new result. -export default function ItemCount({ count, stale = false }: { count?: number; stale?: boolean }) { +// kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which +// does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as +// the new result. `failed`: the count could not be read — a dash that says so, not the icon-only box +// of a first load (E31 review 2). +export default function ItemCount({ + count, + stale = false, + failed = false, +}: { + count?: number; + stale?: boolean; + failed?: boolean; +}) { const { t, i18n } = useTranslation(); const lang = i18n.language; - // `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just - // show the icon until the number arrives. - const label = - count == null + // `count` undefined and not `failed`: a first, un-cached load — keep the fixed box (nothing shifts), + // just show the icon until the number arrives. + const label = failed + ? "—" + : count == null ? "" : t("common.itemCount", { count, @@ -31,7 +42,9 @@ export default function ItemCount({ count, stale = false }: { count?: number; st return ( @@ -39,6 +52,8 @@ export default function ItemCount({ count, stale = false }: { count?: number; st {label} + {failed && {t("common.countFailed")}} + {stale && !failed && {t("common.countUpdating")}} ); } diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index 888ef7ce..948951c9 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -8,9 +8,11 @@ import CountBadge from "./CountBadge"; // the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up. // Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px: // the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there -// it is a horizontal row that scrolls. Callers persist the active tab with lib/storage's -// useTabPersistedState (per browser tab, E31 S4b): a reload keeps the user where they were, and a -// fresh tab opens on the first sub-tab rather than wherever the last visit ended. +// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with +// lib/storage's useTabPersistedState (per browser tab, E31 S4b: Admin Users, Config, Settings, Stats): +// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every +// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page +// and the player settings start on their first tab each time. // // An ARIA tab list (E28 review F3), not a row of toggle buttons: `tablist` / `tab` / `aria-selected`, // ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the diff --git a/frontend/src/components/ui/mediaCardStyles.ts b/frontend/src/components/ui/mediaCardStyles.ts index c641023e..e976424f 100644 --- a/frontend/src/components/ui/mediaCardStyles.ts +++ b/frontend/src/components/ui/mediaCardStyles.ts @@ -19,23 +19,28 @@ export const MEDIA_BADGE = "pointer-events-none absolute rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums"; /** The picture box as a size container (`pic`), so what sits on the picture can answer its width. - * Every picture box carries it: `mediaPictureBox` and the video card's own box. */ -export const MEDIA_PICTURE_CONTAINER = "@container/pic"; + * Every picture box carries it: `mediaPictureBox` and the video card's own box. A box that holds a + * hover row (MEDIA_BAR) also sets `--bar-room`, the band the row needs: the narrow-picture rule + * below lifts the Play by it, and a box with no row (the feed's row and tile views, a Plex card + * without actions) lifts it by nothing (E31 review 2: it sat off-centre there). */ +export const MEDIA_PICTURE_CONTAINER = "@container/pic has-[.media-bar]:[--bar-room:2.5rem]"; /** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row). * On a narrow picture the Play centres in the band ABOVE the hover row instead of on the whole * picture: the picture's height follows the card's width, but the Play and the row grow with the * text size, so at 1.3 a 242px thumbnail put the 63px Play 13px into the 37px row (E31 S5, - * measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide. */ + * measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide. + * Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */ export const MEDIA_HOVER_LAYER = - "reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-10"; + "reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-(--bar-room)"; /** The layer that holds a started title's Continue + Restart pair over the picture: a sibling of the * picture's button, never inside it (C5). Narrow, it leaves the hover row its band as the Play does. */ -export const MEDIA_RESUME_LAYER = `${HOVER_REVEAL} pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto @max-[14rem]/pic:pb-10`; +export const MEDIA_RESUME_LAYER = `${HOVER_REVEAL} pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto @max-[14rem]/pic:pb-(--bar-room)`; -/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. */ -export const MEDIA_BAR = `${HOVER_REVEAL} absolute bottom-2.5 right-1.5 z-base`; +/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. + * `media-bar` marks it for the picture box's `--bar-room` (MEDIA_PICTURE_CONTAINER). */ +export const MEDIA_BAR = `${HOVER_REVEAL} media-bar absolute bottom-2.5 right-1.5 z-base`; /** The title's type, one size per density. On a SHELF (any horizontal strip: Home, the Plex rails, * a collection strip) it is one line, cut with an ellipsis, over one meta line — every card on the diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index c1ca9c55..50212267 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -10,6 +10,9 @@ "undo": "Undo", "redo": "Redo", "loading": "Loading…", + "loadMoreFailed": "Couldn't load more", + "countFailed": "Couldn't count the items", + "countUpdating": "updating", "somethingWrong": "Something went wrong", "itemCount_one": "{{formattedCount}} item", "itemCount_other": "{{formattedCount}} items", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 93c2ef68..e50e5c1d 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -10,6 +10,9 @@ "undo": "Visszavonás", "redo": "Újra", "loading": "Betöltés…", + "loadMoreFailed": "Nem sikerült betölteni a továbbiakat", + "countFailed": "Nem sikerült megszámolni az elemeket", + "countUpdating": "frissül", "somethingWrong": "Hiba történt", "itemCount_one": "{{formattedCount}} elem", "itemCount_other": "{{formattedCount}} elem", diff --git a/frontend/src/lib/nextPage.wiring.contract.test.ts b/frontend/src/lib/nextPage.wiring.contract.test.ts index a12aa837..581cfb56 100644 --- a/frontend/src/lib/nextPage.wiring.contract.test.ts +++ b/frontend/src/lib/nextPage.wiring.contract.test.ts @@ -29,4 +29,34 @@ describe("an infinite list's read-ahead", () => { it("the feed hands the grid the query's isFetching", () => { expect(source("Feed.tsx")).toMatch(/isFetching=\{query\.isFetching\}/); }); + + // Retry is off, so a failed page settles at once; a read-ahead that re-runs on the settle would + // request it again once a round-trip while the end is in view (E31 review 2). + it("stands down at a failed next page", () => { + expect(source("Feed.tsx")).toMatch( + /hasNextPage=\{!!hasNextPage && !query\.isFetchNextPageError\}/, + ); + expect(source("PlexBrowse.tsx")).toMatch( + /const hasNextPage = browseQ\.hasNextPage && !isFetchNextPageError;/, + ); + }); +}); + +// A click joins an in-flight next page but cancels a background refetch (nextPageOnClick): through +// NEXT_PAGE it would join the refetch and load nothing. NEXT_PAGE is only for the read-aheads, so a +// click site that goes back to it fails here (E31 review 2: the YouTube "Load more" was missed). +describe("a click that loads the next page", () => { + const READ_AHEADS = new Set(["ui/VirtualGrid.tsx", "PlexBrowse.tsx"]); + it("uses nextPageOnClick; NEXT_PAGE only in the read-aheads", () => { + const users = Object.entries(files) + .filter(([, src]) => /fetchNextPage\(NEXT_PAGE\)/.test(src as string)) + .map(([path]) => path.replace("../components/", "")); + expect(users.sort()).toEqual([...READ_AHEADS].sort()); + for (const [file, sites] of [ + ["AdminLogs.tsx", 2], + ["Feed.tsx", 2], + ["PlexBrowse.tsx", 1], + ] as const) + expect(source(file).match(/fetchNextPage\(nextPageOnClick\(/g)?.length, file).toBe(sites); + }); }); diff --git a/frontend/src/lib/plexImage.contract.test.ts b/frontend/src/lib/plexImage.contract.test.ts index 9315f0d2..ea996bce 100644 --- a/frontend/src/lib/plexImage.contract.test.ts +++ b/frontend/src/lib/plexImage.contract.test.ts @@ -47,8 +47,10 @@ it("names each backdrop cut by the width the proxy cuts it at", () => { // Every small Plex picture asks for its card-size cut through cardImage (E31 S4e, swept in S5: the // playlist rows, the search box and the collection picker still fetched the 400px poster for a 32px -// picture). A bare `src={…thumb}` is allowed only where the full cut is the point or the URL is not -// the proxy's; the SET is pinned, so a new bare one fails here. +// picture). A plain `src={…}` is allowed only where the full cut is the point or the URL is not the +// proxy's; the SET is pinned, so a new one fails here. Every 's src counts, wherever it sits +// among the attributes and whatever its variable is called: the first cut of this test read only a +// leading `src={…thumb…}` and missed two of the three sites it was written for (E31 review 2). const plexFiles = import.meta.glob( [ "../components/{Plex,plex}*.tsx", @@ -58,7 +60,10 @@ const plexFiles = import.meta.glob( { query: "?raw", import: "default", eager: true }, ); const ALLOWED = new Set([ + "plexCards.tsx pic.src", // cardImage's own src (srcSet/sizes beside it): the card cut "plexCards.tsx c.thumb", // cast photos: the person-image route, not the image proxy + "PlexBrowse.tsx p.photo", // the person filter chip's headshot: the person-image route + "plexViews.tsx d.photo", // the person page's headshot: the same "PlexInfo.tsx c.thumb", // the same cast photos on the detail page "PlexInfo.tsx detail.thumb", // the detail page's own poster: the full cut is the point "plexDetailUi.tsx thumb", // the show/season header poster: the same @@ -69,7 +74,7 @@ it("leaves no small Plex picture on the full cut", () => { expect(Object.keys(plexFiles).length).toBeGreaterThan(5); const bare = new Set(); for (const [path, src] of Object.entries(plexFiles)) - for (const m of (src as string).matchAll(/]*?\bsrc=\{([^}]*)\}/g)) bare.add(`${path.split("/").pop()} ${m[1]!.trim()}`); expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]); }); From a4775e1cd067338b09f046de293e49701f898604 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 02:43:52 +0200 Subject: [PATCH 30/31] fix(ui): the filter rail shows no tag counts until the facets land It printed the tag's whole-library count as the current filter's while the facets loaded, and for good when they failed: live, "Music 238" beside a search that matched 3 of its channels. Ordering still falls back to it. --- frontend/src/components/Sidebar.tsx | 11 +++++------ frontend/src/components/facetOption.test.ts | 19 +++++++++++++++++++ frontend/src/components/facetOption.ts | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/facetOption.test.ts create mode 100644 frontend/src/components/facetOption.ts diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1de2d9a6..8d374ac3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -14,9 +14,10 @@ import FilterRail from "./FilterRail"; import { type PanelItem } from "./ui/PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; +import { facetOption } from "./facetOption"; import DateFilterSelect from "./DateFilterSelect"; import YearFilter from "./YearFilter"; -import Combobox, { type ComboboxOption } from "./ui/Combobox"; +import Combobox from "./ui/Combobox"; import MatchToggle from "./ui/MatchToggle"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useChannelsActions } from "./channelsContext"; @@ -72,6 +73,8 @@ export default function Sidebar({ }); const facetsReady = !!summaryQuery.data; const facetCounts = summaryQuery.data?.facets ?? {}; + // Ordering falls back to the tag's whole-library count until the facets land; the number shown does + // not (facetOption). const chipCount = (tag: Tag): number => facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count; const visibleChips = (list: Tag[]): Tag[] => { @@ -93,11 +96,7 @@ export default function Sidebar({ // The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns // only its own slice: its selection replaces the ids that belong to its list, leaving the others. - const tagOption = (tg: Tag): ComboboxOption => ({ - value: String(tg.id), - label: tg.name, - count: chipCount(tg), - }); + const tagOption = (tg: Tag) => facetOption(tg, summaryQuery.data?.facets); const selectedIn = (list: Tag[]): string[] => filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String); function setTagSelection(list: Tag[], next: string[]) { diff --git a/frontend/src/components/facetOption.test.ts b/frontend/src/components/facetOption.test.ts new file mode 100644 index 00000000..1a052638 --- /dev/null +++ b/frontend/src/components/facetOption.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import type { Tag } from "../lib/api"; +import { facetOption } from "./facetOption"; + +const music = { id: 7, name: "Music", category: "topic", channel_count: 238 } as Tag; + +describe("a tag as a filter row", () => { + it("counts the current filter's channels", () => { + expect(facetOption(music, { "7": 3 }).count).toBe(3); + expect(facetOption(music, {}).count).toBe(0); + }); + + // E31 review 2, live: with the summary failed, the rail read "Music 238" beside a search that matched + // 3 Music channels — the whole library's count printed as the filter's. + it("shows no count until the facets land, rather than the whole library's", () => { + expect(facetOption(music, undefined).count).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/facetOption.ts b/frontend/src/components/facetOption.ts new file mode 100644 index 00000000..ec888623 --- /dev/null +++ b/frontend/src/components/facetOption.ts @@ -0,0 +1,14 @@ +import type { Tag } from "../lib/api"; +import type { ComboboxOption } from "./ui/Combobox"; + +// A tag as a filter row: its count is the CURRENT filter's (the summary's facets), or none at all while +// the facets load or after they failed. The rail printed the tag's whole-library count in that gap, as +// if it were the filter's: "Music 238" beside a search that matched 3 of its channels (E31 review 2). +export const facetOption = ( + tag: Tag, + facets: Record | undefined, +): ComboboxOption => ({ + value: String(tag.id), + label: tag.name, + count: facets ? (facets[String(tag.id)] ?? 0) : undefined, +}); From f9c37140256de26dbcbb17065f80b80523ed53da Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 02:58:07 +0200 Subject: [PATCH 31/31] =?UTF-8?q?release:=200.98.0=20notes=20=E2=80=94=20d?= =?UTF-8?q?esign=20review=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 14 ++++++++++++++ frontend/src/content/release-notes/hu.json | 14 ++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e11557f7..216079a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.98.0 — 2026-10-03 + +### New + +- Search is much faster: a short or common word now answers in a fraction of a second, with the newest matches ranked by relevance. +- The feed, the channel list and the Plex pages load faster, and Plex cards fetch a picture sized to the card. +- While a new search counts its results, the count dims instead of showing the old number. + +### Fixed + +- A list whose next page failed to load says so and offers Retry, instead of asking again and again. +- The filter rail's tag counts follow the current search. +- Smaller fixes to layout, keyboard focus and wording. + ## 0.97.0 — 2026-10-01 ### New diff --git a/VERSION b/VERSION index 05e39cbb..95fce8ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.97.0 +0.98.0 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index 213ac478..4a97a857 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,18 @@ [ + { + "version": "0.98.0", + "date": "2026-10-03", + "new": [ + "Search is much faster: a short or common word now answers in a fraction of a second, with the newest matches ranked by relevance.", + "The feed, the channel list and the Plex pages load faster, and Plex cards fetch a picture sized to the card.", + "While a new search counts its results, the count dims instead of showing the old number." + ], + "fixed": [ + "A list whose next page failed to load says so and offers Retry, instead of asking again and again.", + "The filter rail's tag counts follow the current search.", + "Smaller fixes to layout, keyboard focus and wording." + ] + }, { "version": "0.97.0", "date": "2026-10-01", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index db97e619..045da425 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,18 @@ [ + { + "version": "0.98.0", + "date": "2026-10-03", + "new": [ + "Sokkal gyorsabb a keresés: egy rövid vagy gyakori szó is a másodperc törtrésze alatt válaszol, a legújabb találatok relevancia szerint rendezve.", + "Gyorsabban töltődik a hírfolyam, a csatornalista és a Plex-oldalak, a Plex-kártyák pedig a kártyához illő méretű képet kérnek.", + "Amíg egy új keresés számolja a találatokat, a szám elhalványul, nem a régit mutatja." + ], + "fixed": [ + "Ha egy lista következő oldala nem töltődik be, ezt jelzi, és újrapróbálást kínál, nem kéri újra meg újra.", + "A szűrősáv címkeszámai a mostani keresést követik.", + "Kisebb javítások az elrendezésben, a billentyűzetes fókuszban és a szövegekben." + ] + }, { "version": "0.97.0", "date": "2026-10-01",