From ec22eefd31ef0583b0ba306a1feae26b1352a67f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 21:17:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20E31=20S2=20=E2=80=94=20the=20tab=20n?= =?UTF-8?q?ames=20the=20object,=20the=20channel=20header=20wraps,=20the=20?= =?UTF-8?q?player=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