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
|
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.
|
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
|
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
|
space is, what hugs one side, what the eye lands on first. Then the screen at 125% and 133% browser
|
||||||
the largest text size. Both are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6).
|
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
|
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.
|
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.
|
(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]
|
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.
|
- **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
|
Every screen holds at 125% and 133% browser zoom on a 1080p desktop window (what the owner uses: the
|
||||||
1024 wide: nothing clipped or overlapping, no sideways page scroll, every control reachable and at
|
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,
|
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]
|
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 | 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 | 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-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
|
{/* 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
|
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. */}
|
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
|
<Tabs
|
||||||
idBase={panel.idBase}
|
idBase={panel.idBase}
|
||||||
className="flex gap-1"
|
className="flex gap-1"
|
||||||
@@ -425,7 +427,7 @@ export default function ChannelPage({
|
|||||||
{ id: "about", label: t("channel.tabAbout") },
|
{ 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
|
<IconLink
|
||||||
href={ytUrl}
|
href={ytUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -542,14 +544,16 @@ export default function ChannelPage({
|
|||||||
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||||
channel you actually follow. */}
|
channel you actually follow. */}
|
||||||
{ch?.subscribed && !me.is_demo && (
|
{ch?.subscribed && !me.is_demo && (
|
||||||
<ChannelTagsControl
|
<div className="min-w-0 flex-1">
|
||||||
userTags={userTags}
|
<ChannelTagsControl
|
||||||
attachedIds={ch.tag_ids}
|
userTags={userTags}
|
||||||
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
|
attachedIds={ch.tag_ids}
|
||||||
onCreate={(name) => createAndAttachTag(channelId, name)}
|
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
|
||||||
// The header row has free width — show tags inline instead of collapsing to "+N".
|
onCreate={(name) => createAndAttachTag(channelId, name)}
|
||||||
bounded={false}
|
// The header row's leftover width: tags inline, "+N" only when they do not fit.
|
||||||
/>
|
bounded={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
|
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
|
||||||
// bookkeeping.
|
// bookkeeping.
|
||||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
import { NavigationProvider } from "./NavigationProvider";
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
import { PageHeading } from "./ui/Headings";
|
||||||
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
@@ -88,6 +89,41 @@ describe("NavigationProvider · the tab title", () => {
|
|||||||
expect(document.title).not.toBe("Siftlode");
|
expect(document.title).not.toBe("Siftlode");
|
||||||
expect(document.title.endsWith(" · Siftlode")).toBe(true);
|
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", () => {
|
describe("NavigationProvider · setPage's same-page guard", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMe } from "../lib/useMe";
|
|||||||
import { urlWithParams, type Page } from "../lib/urlState";
|
import { urlWithParams, type Page } from "../lib/urlState";
|
||||||
import { LOGS_KEYS } from "../lib/logsUrlState";
|
import { LOGS_KEYS } from "../lib/logsUrlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { pageTitleKey } from "../lib/pageMeta";
|
||||||
|
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||||
import { setAccountRaw } from "../lib/storage";
|
import { setAccountRaw } from "../lib/storage";
|
||||||
import { setNavigator } from "../lib/nav";
|
import { setNavigator } from "../lib/nav";
|
||||||
import {
|
import {
|
||||||
@@ -164,21 +165,17 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
if (page !== "feed") setYtSearch(null);
|
if (page !== "feed") setYtSearch(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
|
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
|
||||||
// shows the channel name. Signed out, the page on screen is the public landing whatever `page`
|
// "<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).
|
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
|
||||||
const signedIn = useMe() != null;
|
const signedIn = useMe() != null;
|
||||||
|
const objectTitle = useDocumentObjectTitle();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const brand = "Siftlode";
|
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
|
||||||
const label = !signedIn
|
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
|
||||||
? null
|
}, [signedIn, page, objectTitle, i18n.language, t]);
|
||||||
: channelView
|
|
||||||
? channelView.name || t("header.channelManager")
|
|
||||||
: page === "feed"
|
|
||||||
? null
|
|
||||||
: t(pageTitleKey(page));
|
|
||||||
document.title = label ? `${label} · ${brand}` : brand;
|
|
||||||
}, [signedIn, page, channelView, i18n.language, t]);
|
|
||||||
|
|
||||||
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
|
// 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.
|
// 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/);
|
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 { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigationActions } from "./navigationContext";
|
import { useNavigationActions } from "./navigationContext";
|
||||||
@@ -181,6 +189,25 @@ export default function PlayerModal({
|
|||||||
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
|
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
|
||||||
// dep lists too.
|
// dep lists too.
|
||||||
const focusModal = useCallback(() => cardRef.current?.focus(), []);
|
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
|
// 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
|
// 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
|
// 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.
|
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. */}
|
Hidden on narrow screens where there's no room beside the card. */}
|
||||||
<div
|
<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 && (
|
{hasQueue && (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { CheckRow } from "./ui/NativeControls";
|
|||||||
import { useReturnFocus } from "./ui/useReturnFocus";
|
import { useReturnFocus } from "./ui/useReturnFocus";
|
||||||
import { MoreButton } from "./ui/MoreButton";
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
||||||
|
import { PageHeading } from "./ui/Headings";
|
||||||
import { useCopyItems } from "./copyItems";
|
import { useCopyItems } from "./copyItems";
|
||||||
|
|
||||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
// 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")}
|
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">
|
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||||
{linked && detail.dirty && (
|
{linked && detail.dirty && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { vocab } from "../lib/vocab";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
|
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
@@ -262,7 +263,7 @@ export default function PlexCollectionView({
|
|||||||
const meta = [
|
const meta = [
|
||||||
d?.year_min != null &&
|
d?.year_min != null &&
|
||||||
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
|
(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 }),
|
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
|
||||||
formatRuntime(d?.total_runtime_s, lang),
|
formatRuntime(d?.total_runtime_s, lang),
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
i18n: { language: "en" },
|
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>) =>
|
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"),
|
label: t("plex.info.facts.cert"),
|
||||||
node: (
|
node: (
|
||||||
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
||||||
{cert}
|
{vocab(t, "certificate", cert)}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -589,7 +589,7 @@ export default function PlexInfo({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detail.content_rating}
|
{vocab(t, "certificate", detail.content_rating)}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
{detail.duration_seconds ? (
|
{detail.duration_seconds ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { vocab } from "../lib/vocab";
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
||||||
@@ -247,7 +248,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
body: (
|
body: (
|
||||||
<Combobox
|
<Combobox
|
||||||
label={t("plex.filter.contentRating")}
|
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}
|
selected={filters.contentRatings}
|
||||||
onChange={(next) => patch({ contentRatings: next })}
|
onChange={(next) => patch({ contentRatings: next })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ export function PlexShowView({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{show.content_rating}
|
{vocab(t, "certificate", show.content_rating)}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
{show.season_count != null && (
|
{show.season_count != null && (
|
||||||
@@ -838,7 +838,10 @@ export function PlexSeasonView({
|
|||||||
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
|
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
|
||||||
{d.show.title}
|
{d.show.title}
|
||||||
</Button>
|
</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)}
|
{seasonTitle(se, t)}
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
<p className="text-sm text-muted mt-1">
|
<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";
|
import { useOwnPageHeading } from "./pageHeading";
|
||||||
|
|
||||||
// The page's heading outline (B4, UX-15): one `h1` per page, then a heading per section. A screen
|
// 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.
|
// 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
|
/** 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. */
|
* shell's hidden page name, and the object the browser tab names (`lib/documentTitle`). `tabTitle`
|
||||||
export function PageHeading(props: ComponentPropsWithoutRef<"h1">) {
|
* 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();
|
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
|
/** 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: {
|
filters: {
|
||||||
...EMPTY_PLEX_FILTERS,
|
...EMPTY_PLEX_FILTERS,
|
||||||
genres: ["Action"],
|
genres: ["Action"],
|
||||||
|
contentRatings: ["Not Rated"],
|
||||||
// "A" was picked in the sidebar (its title came along); "B" arrived from a shared LINK, which
|
// "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.
|
// carries ids only — the case that used to render a raw rating_key.
|
||||||
collections: ["A", "B"],
|
collections: ["A", "B"],
|
||||||
@@ -64,6 +65,13 @@ describe("usePlexActiveFilters", () => {
|
|||||||
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
|
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", () => {
|
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)
|
// Seed the resolution the hook would fetch, so this asserts the WIRING (hook -> builder -> label)
|
||||||
// rather than the network.
|
// rather than the network.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { vocabI18nKey } from "../lib/vocab";
|
||||||
import { type ActiveFilter } from "../lib/useActiveFilters";
|
import { type ActiveFilter } from "../lib/useActiveFilters";
|
||||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
@@ -53,7 +54,9 @@ export function buildPlexActiveFilters(
|
|||||||
for (const r of filters.contentRatings) {
|
for (const r of filters.contentRatings) {
|
||||||
out.push({
|
out.push({
|
||||||
key: `contentRating:${r}`,
|
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) }),
|
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,5 +165,11 @@
|
|||||||
"mono": "Mono",
|
"mono": "Mono",
|
||||||
"audioN": "Audio {{n}}",
|
"audioN": "Audio {{n}}",
|
||||||
"subtitleN": "Subtitle {{n}}"
|
"subtitleN": "Subtitle {{n}}"
|
||||||
|
},
|
||||||
|
"certificate": {
|
||||||
|
"NotRated": "Not Rated",
|
||||||
|
"Unrated": "Unrated",
|
||||||
|
"Approved": "Approved",
|
||||||
|
"Passed": "Passed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,5 +165,11 @@
|
|||||||
"mono": "monó",
|
"mono": "monó",
|
||||||
"audioN": "{{n}}. hangsáv",
|
"audioN": "{{n}}. hangsáv",
|
||||||
"subtitleN": "{{n}}. felirat"
|
"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"],
|
["autoTag", "Hungarian", "Magyar"],
|
||||||
["profile", "Video only (MP4)", "Csak videó (MP4)"],
|
["profile", "Video only (MP4)", "Csak videó (MP4)"],
|
||||||
["topic", "Lifestyle_(sociology)", "Életmód"],
|
["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) => {
|
] as const)("%s %j reads %j in Hungarian", (group, word, shown) => {
|
||||||
expect(vocab(hu, group, word)).toBe(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");
|
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", () => {
|
it("an unknown word is shown, not hidden or keyed", () => {
|
||||||
expect(vocab(hu, "crewJob", "Gaffer")).toBe("Gaffer");
|
expect(vocab(hu, "crewJob", "Gaffer")).toBe("Gaffer");
|
||||||
expect(vocab(hu, "topic", "Knitting_(craft)", "Knitting (craft)")).toBe("Knitting (craft)");
|
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";
|
import type { PlexTrackNaming } from "./api";
|
||||||
|
|
||||||
// E30: the closed vocabularies the server stores in ENGLISH — a TMDB crew job or department, an
|
// 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
|
// 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
|
// `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.
|
// 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"). */
|
/** A vocabulary word's key: its letters and digits ("Health & Fitness" → "HealthFitness"). */
|
||||||
export function vocabKey(word: string): string {
|
export function vocabKey(word: string): string {
|
||||||
return word.replace(/[^A-Za-z0-9]/g, "");
|
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
|
/** One stored English word of `group`, in the reader's language (`fallback`, by default the word
|
||||||
* itself, when the area does not hold it). */
|
* itself, when the area does not hold it). */
|
||||||
export function vocab(t: TFunction, group: Group, word: string, fallback = word): string {
|
export function vocab(t: TFunction, group: Group, word: string, fallback = word): string {
|
||||||
const key = vocabKey(word);
|
const key = vocabI18nKey(group, word);
|
||||||
return key ? t(`vocab.${group}.${key}`, { defaultValue: fallback }) : fallback;
|
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
|
/** 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