fix(ui): E31 S2 — the tab names the object, the channel header wraps, the player fits a short window
- 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 "<object> · <module> · 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).
This commit is contained in:
+6
-4
@@ -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 |
|
||||
|
||||
@@ -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. */}
|
||||
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
|
||||
{/* 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". */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3 border-b border-border pb-2">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
className="flex gap-1"
|
||||
@@ -425,7 +427,7 @@ export default function ChannelPage({
|
||||
{ id: "about", label: t("channel.tabAbout") },
|
||||
]}
|
||||
/>
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<div className="ml-2 flex min-w-0 flex-1 items-center gap-2">
|
||||
<IconLink
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
@@ -542,14 +544,16 @@ export default function ChannelPage({
|
||||
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||
channel you actually follow. */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelTagsControl
|
||||
userTags={userTags}
|
||||
attachedIds={ch.tag_ids}
|
||||
onToggle={(tagId) => 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".
|
||||
// The header row's leftover width: tags inline, "+N" only when they do not fit.
|
||||
bounded={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 : <PageHeading>{name}</PageHeading>;
|
||||
}
|
||||
const view = rtlRender(<View name="" />, { wrapper });
|
||||
await settle();
|
||||
expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone
|
||||
|
||||
view.rerender(<View name="Nicolas Cage" />);
|
||||
await settle();
|
||||
expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode");
|
||||
|
||||
view.rerender(<View name={null} />);
|
||||
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(<PageHeading tabTitle="Voyager · Season 1">Season 1</PageHeading>, { wrapper });
|
||||
await settle();
|
||||
expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
|
||||
@@ -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"):
|
||||
// "<object> · <module> · 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.
|
||||
|
||||
@@ -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(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||
expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */}
|
||||
<div
|
||||
className={`relative flex w-full max-h-full ${hasQueue ? "max-w-[63rem] sm:px-14" : "max-w-4xl"}`}
|
||||
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).
|
||||
style={{
|
||||
maxWidth: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
|
||||
hasQueue ? " + 7rem" : ""
|
||||
}))`,
|
||||
}}
|
||||
>
|
||||
{hasQueue && (
|
||||
<Pressable
|
||||
|
||||
@@ -63,6 +63,7 @@ import { CheckRow } from "./ui/NativeControls";
|
||||
import { useReturnFocus } from "./ui/useReturnFocus";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import { useCopyItems } from "./copyItems";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
@@ -875,7 +876,9 @@ export default function Playlists() {
|
||||
aria-label={t("playlists.rename")}
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
<PageHeading className="text-lg font-semibold truncate">
|
||||
{plName(detail)}
|
||||
</PageHeading>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
opts?.name ? `${key}:${opts.name}` : key,
|
||||
opts?.name
|
||||
? `${key}:${opts.name}`
|
||||
: key.startsWith("vocab.certificate.") && typeof opts?.defaultValue === "string"
|
||||
? opts.defaultValue
|
||||
: key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -187,7 +187,7 @@ function FactsPanel({
|
||||
label: t("plex.info.facts.cert"),
|
||||
node: (
|
||||
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
||||
{cert}
|
||||
{vocab(t, "certificate", cert)}
|
||||
</Filterable>
|
||||
),
|
||||
});
|
||||
@@ -589,7 +589,7 @@ export default function PlexInfo({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{detail.content_rating}
|
||||
{vocab(t, "certificate", detail.content_rating)}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.duration_seconds ? (
|
||||
|
||||
@@ -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: (
|
||||
<Combobox
|
||||
label={t("plex.filter.contentRating")}
|
||||
options={facets.content_ratings}
|
||||
options={facets.content_ratings.map((o) => ({
|
||||
...o,
|
||||
label: vocab(t, "certificate", o.value),
|
||||
}))}
|
||||
selected={filters.contentRatings}
|
||||
onChange={(next) => patch({ contentRatings: next })}
|
||||
/>
|
||||
|
||||
@@ -583,7 +583,7 @@ export function PlexShowView({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{show.content_rating}
|
||||
{vocab(t, "certificate", show.content_rating)}
|
||||
</Filterable>
|
||||
)}
|
||||
{show.season_count != null && (
|
||||
@@ -838,7 +838,10 @@ export function PlexSeasonView({
|
||||
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
|
||||
{d.show.title}
|
||||
</Button>
|
||||
<PageHeading className="text-2xl font-semibold leading-tight">
|
||||
<PageHeading
|
||||
className="text-2xl font-semibold leading-tight"
|
||||
tabTitle={`${d.show.title} · ${seasonTitle(se, t)}`}
|
||||
>
|
||||
{seasonTitle(se, t)}
|
||||
</PageHeading>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
|
||||
@@ -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 <h1 {...props} />;
|
||||
const ref = useRef<HTMLHeadingElement>(null);
|
||||
useNameTabAfter(ref, tabTitle);
|
||||
return <h1 ref={ref} {...props} />;
|
||||
}
|
||||
|
||||
/** A section's caption: the small uppercase muted label, as a heading. `level` 3 inside a dialog or
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,5 +165,11 @@
|
||||
"mono": "Mono",
|
||||
"audioN": "Audio {{n}}",
|
||||
"subtitleN": "Subtitle {{n}}"
|
||||
},
|
||||
"certificate": {
|
||||
"NotRated": "Not Rated",
|
||||
"Unrated": "Unrated",
|
||||
"Approved": "Approved",
|
||||
"Passed": "Passed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement | null>, override?: string): void {
|
||||
const entry = useRef<Entry | null>(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();
|
||||
});
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user