refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
//
// jsdom only because the module graph reaches `lib/api` -> `i18n`, which reads localStorage at
// import time; nothing here touches the DOM.
// The two pure rules behind the comfort shelf. Both are one-liners that eight call sites
// depend on, and both are exactly the kind of mapping that rots silently: a wrong pin kind is a 404
// nobody sees until they try that one surface, and a move rule that disagrees with the menu row's
// disabled state shows an enabled row that no-ops.
import { describe, it, expect } from "vitest";
import { pinTargetForCard, shelfMoveTarget } from "./comfortPin";
import type { DiscoverCard } from "../../lib/api";
const card = (over: Partial<DiscoverCard>): DiscoverCard =>
({
source: "youtube",
kind: "video",
id: "x",
title: "t",
subtitle: null,
thumb: null,
aspect: "wide",
duration_seconds: null,
position_seconds: 0,
remaining_seconds: null,
status: "new",
video: null,
plex: null,
...over,
}) as DiscoverCard;
describe("pinTargetForCard", () => {
it("pins a Plex SHOW as a show and every other Plex tile as a leaf item", () => {
// The distinction the whole shelf turns on: "put that series on" has to resolve to an episode,
// so a show may not be stored as the item it is browsed as.
expect(pinTargetForCard(card({ source: "plex", kind: "show", id: "1234" }))).toEqual({
kind: "plex_show",
ref: "1234",
});
expect(pinTargetForCard(card({ source: "plex", kind: "movie", id: "99" }))).toEqual({
kind: "plex_item",
ref: "99",
});
expect(pinTargetForCard(card({ source: "plex", kind: "episode", id: "77" }))).toEqual({
kind: "plex_item",
ref: "77",
});
});
it("maps the three YouTube kinds to their own columns", () => {
expect(pinTargetForCard(card({ kind: "video", id: "vid" }))).toEqual({
kind: "video",
ref: "vid",
});
expect(pinTargetForCard(card({ kind: "channel", id: "UC1" }))).toEqual({
kind: "channel",
ref: "UC1",
});
expect(pinTargetForCard(card({ kind: "playlist", id: "12" }))).toEqual({
kind: "playlist",
ref: "12",
});
});
});
describe("shelfMoveTarget", () => {
it("moves within the shelf", () => {
expect(shelfMoveTarget(2, "first", 4)).toBe(0);
expect(shelfMoveTarget(2, "left", 4)).toBe(1);
expect(shelfMoveTarget(2, "right", 4)).toBe(3);
expect(shelfMoveTarget(2, "last", 4)).toBe(3);
});
it("reports -1 for every move that would go nowhere", () => {
// This is what the menu reads to disable a row, so each of these is a row the user must not be
// able to press onto a no-op.
expect(shelfMoveTarget(0, "first", 4)).toBe(-1);
expect(shelfMoveTarget(0, "left", 4)).toBe(-1);
expect(shelfMoveTarget(3, "right", 4)).toBe(-1);
expect(shelfMoveTarget(3, "last", 4)).toBe(-1);
// A single-item shelf can do none of the four.
expect(shelfMoveTarget(0, "right", 1)).toBe(-1);
expect(shelfMoveTarget(0, "last", 1)).toBe(-1);
});
it("reports -1 for an index that is not on the shelf", () => {
// `findIndex` returns -1 when the shelf has already been refetched without that pin — the menu
// may still be open over the tile that was there a moment ago.
expect(shelfMoveTarget(-1, "right", 4)).toBe(-1);
expect(shelfMoveTarget(9, "left", 4)).toBe(-1);
});
});
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// A Plex query follows a language switch. `plexLang()` read the i18n singleton, and under the
// React Compiler a value with no reactive input is computed once: after switching to Hungarian the
// comfort shelf kept asking for its English cards. The language now comes off useTranslation. Real
// i18n (the key-echo mock does not re-render on a switch, which is the very thing under test).
import { describe, it, expect, afterEach, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, act, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setLanguage } from "../../i18n";
import { api } from "../../lib/api";
import { useComfortPin } from "./comfortPin";
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
// run that render landed after the test environment was gone ("window is not defined").
afterEach(() => {
cleanup();
vi.restoreAllMocks();
setLanguage("en");
});
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("the comfort shelf after a language switch", () => {
it("asks for its cards in the new language", async () => {
const get = vi.spyOn(api, "comfortPins").mockImplementation(() => new Promise(() => {}));
setLanguage("en");
renderHook(() => useComfortPin(true), { wrapper });
await waitFor(() => expect(get).toHaveBeenCalledWith("en"));
act(() => setLanguage("hu"));
await waitFor(() => expect(get).toHaveBeenCalledWith("hu"));
});
});
@@ -0,0 +1,212 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
// "put this on my comfort shelf", from wherever you happen to be looking at the thing.
//
// The shelf is the Home surface's object, but the GESTURE belongs wherever a pinnable thing is
// shown — a video, a channel, a playlist, a Plex show or movie: you decide something is comfort media
// while you are watching it. (Locked decision 3: an episode, a season and a person are not pin kinds,
// so their cards and strips carry no pin.) This file is the one place the pin lives; every surface
// composes the same `ActionMenuItem` into its object's ONE action list: the video, Plex and
// channel actions (`useVideoActions`, `usePlexCardActions`, `Channels`), the playlist rail and page,
// and Home's shelves. There is no pin-only menu any more.
//
// Feature layer, not `components/ui/`: it speaks a domain API slice, which the platform boundary
// (eslint `no-restricted-imports`) forbids a `ui/` primitive from doing.
/** What the API needs to pin something: the kind, and the id the rest of the API already speaks for
* that kind (a video id, a channel id, a Plex `rating_key`, a playlist id). Mirrors
* `app/discover/pins.py::PIN_COLUMNS`. */
export interface PinTarget {
kind: PinKind;
ref: string;
}
// Constructors rather than five call sites writing the kind strings by hand. The mapping from a
// domain object to its pin kind is an invariant, so it lives in a function — a `"plex_show"` typo'd
// as `"plex-show"` at one of eight call sites would be a 404 nobody notices until they try it.
export const pinVideo = (videoId: string): PinTarget => ({ kind: "video", ref: videoId });
export const pinChannel = (channelId: string): PinTarget => ({ kind: "channel", ref: channelId });
export const pinPlaylist = (playlistId: number): PinTarget => ({
kind: "playlist",
ref: String(playlistId),
});
/** A Plex tile. A SHOW is pinned as a show ("put that series on" resolves to whichever episode is
* next); a movie or a single episode is pinned as the leaf item it is. */
export const pinPlex = (card: { id: string; type: string }): PinTarget => ({
kind: card.type === "show" ? "plex_show" : "plex_item",
ref: card.id,
});
/** A unified discover card back to the thing it was pinned as — the inverse of the mapping in
* `app/discover/cards.py`. Null for a kind that cannot be pinned. */
export function pinTargetForCard(
card: Pick<DiscoverCard, "source" | "kind" | "id">,
): PinTarget | null {
if (card.source === "plex") return pinPlex({ id: card.id, type: card.kind });
if (card.kind === "video") return pinVideo(card.id);
if (card.kind === "channel") return pinChannel(card.id);
if (card.kind === "playlist") return { kind: "playlist", ref: card.id };
return null;
}
/** Where a shelf move lands. The shelf is a HORIZONTAL rail, so the words are left/right rather than
* the playlist table's up/down — the same four moves, named after what the user actually sees. */
export type ShelfMove = "first" | "left" | "right" | "last";
/** `from` -> the index the move targets, or -1 when there is nowhere to go. Pure, so the disabled
* state of a menu row and the move it would perform are decided by ONE rule instead of two that can
* disagree (a row that looks available and then no-ops is the worst of the three states). */
export function shelfMoveTarget(from: number, to: ShelfMove, length: number): number {
if (from < 0 || from >= length) return -1;
const target =
to === "first" ? 0 : to === "last" ? length - 1 : to === "left" ? from - 1 : from + 1;
return target === from || target < 0 || target >= length ? -1 : target;
}
/** The identity a target is looked up by. One function, so "is this pinned?" and "what did the
* server give back?" can never key the shelf two different ways. */
const targetKey = (target: PinTarget): string => `${target.kind}:${target.ref}`;
export interface ComfortPin {
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
remove: (pinId: number, title: string) => void;
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
item: (target: PinTarget | null, title: string) => ActionMenuItem[];
}
/**
* The shelf's client half.
*
* `open` is the host menu's own open flag. The shelf is only fetched once a menu has actually been
* opened (and stays cached afterwards), because the pinned-ness of a row is needed to LABEL one menu
* item and nothing else — firing `/api/discover/pins`, which resolves a `next` per non-leaf pin, on
* every feed load would be a real request for a string almost nobody reads that session.
*/
export function useComfortPin(open: boolean): ComfortPin {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
// Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides
// WHEN the first fetch happens, not whether the answer survives the menu closing.
const [everOpen, setEverOpen] = useState(false);
// Latched during render: the query below is gated on it, so an effect delayed the first fetch by
// a commit for no reason. Convergent — the write falsifies its own condition.
if (open && !everOpen) setEverOpen(true);
const shelf = useQuery({
queryKey: qk.comfortPins(plexLang(i18n.language)),
queryFn: () => api.comfortPins(plexLang(i18n.language)),
enabled: everOpen,
// The shelf is hand-curated and short; re-asking on every menu open would be noise.
staleTime: 60_000,
});
const pins = shelf.data?.items;
const pinIdOf = useCallback(
(target: PinTarget): number | null | undefined => {
if (!pins) return undefined;
const key = targetKey(target);
for (const card of pins) {
const other = pinTargetForCard(card);
if (other && targetKey(other) === key) return card.pin_id ?? null;
}
return null;
},
[pins],
);
const invalidate = useCallback(() => {
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
// The prefix, not an exact key: the Home payload is per-budget, and pinning something can change
// the hero as well as the shelf.
void qc.invalidateQueries({ queryKey: qk.discover() });
}, [qc]);
const add = useCallback(
async (target: PinTarget, title: string) => {
try {
await api.addComfortPin(target.kind, target.ref);
// The shelf lives on another page, so without this the click has no visible consequence at
// all from the feed — the one place a toast genuinely carries the whole feedback.
notify({ level: "success", message: t("home.pin.added", { title }) });
} catch (e) {
notifyActionError(e, t("home.pin.addFailed"));
}
invalidate();
},
[invalidate, t],
);
const remove = useCallback(
(pinId: number, title: string) => {
void (async () => {
try {
await api.removeComfortPin(pinId);
notify({ level: "success", message: t("home.pin.removed", { title }) });
} catch (e) {
notifyActionError(e, t("home.pin.removeFailed"));
}
invalidate();
})();
},
[invalidate, t],
);
const toggle = useCallback(
(target: PinTarget, title: string) => {
const pinId = pinIdOf(target);
if (pinId == null) void add(target, title);
else remove(pinId, title);
},
[add, pinIdOf, remove],
);
const item = useCallback(
(target: PinTarget | null, title: string): ActionMenuItem[] => {
if (!target) return [];
const pinId = pinIdOf(target);
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
// something that is already on the shelf. Only ever seen on the first menu open of a session.
if (pinId === undefined)
return [
{
id: "comfort-pin",
label: t("home.pin.add"),
icon: <Loader2 className="w-4 h-4 animate-spin" />,
separatorBefore: true,
disabled: true,
onSelect: () => {},
},
];
return [
{
id: "comfort-pin",
label: pinId === null ? t("home.pin.add") : t("home.pin.remove"),
icon: pinId === null ? <Pin className="w-4 h-4" /> : <PinOff className="w-4 h-4" />,
separatorBefore: true,
// One row, two states, and only ONE of them is destructive — so `danger` flips with the
// label and the icon rather than being decided at a call site. The shelf's own hand-rolled
// unpin row on Home got the flag first; leaving this one neutral painted the identical
// command (same label, same icon, same `removeComfortPin`) two different colours on one
// screen — the hero's menu and the Continue-watching rail come through here.
danger: pinId !== null,
onSelect: () => toggle(target, title),
},
];
},
[pinIdOf, t, toggle],
);
return { remove, item };
}
@@ -0,0 +1,385 @@
// @vitest-environment jsdom
//
// The debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the
// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
// lives in a real query cache, as in the app.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugSession, Me } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key,
i18n: { language: "en" },
}),
};
});
const { order, session, startDebugSession, stopDebugSession, hold, past } = vi.hoisted(() => {
const past: { items: unknown[] } = { items: [] };
// A Stop held open until released, when a test needs it in flight.
const hold = { stop: null as Promise<void> | null };
const order: string[] = [];
const session = (over: Partial<DebugSession> = {}): DebugSession => ({
id: "0123456789abcdef",
level: "DEBUG",
started_at: new Date().toISOString(),
until: new Date(Date.now() + 3_600_000).toISOString(),
ended_at: null,
ended_reason: null,
issue_url: null,
pending_files: [],
images_pending: false,
sending: false,
screenshots: 0,
draft: null,
...over,
});
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
Promise.resolve({ ok: true as const, session: session({ level }) }),
);
const stopDebugSession = vi.fn(async (id: string) => {
order.push("stop");
if (hold.stop) await hold.stop;
return {
ok: true as const,
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
};
});
return { order, session, startDebugSession, stopDebugSession, hold, past };
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: {
...actual.api,
startDebugSession,
stopDebugSession,
debugSessions: () => Promise.resolve(past),
},
};
});
vi.mock("../../lib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/diagnostics")>();
return {
...actual,
flushNow: () => {
order.push("flush");
return Promise.resolve();
},
};
});
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
import Modal from "../../components/ui/Modal";
import Toaster from "../../components/ui/Toaster";
import {
closeComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
} from "../../lib/debugRecorder";
import {
anyBlockingLayer,
anyBlockingLayerAbove,
pushLayer,
removeLayer,
} from "../../lib/layerStack";
import { events } from "../../lib/log";
beforeEach(() => {
order.length = 0;
hold.stop = null;
sessionStorage.clear();
});
afterEach(() => {
cleanup();
closeComposer();
setStepPrompt(null);
vi.clearAllMocks();
vi.useRealTimers();
});
let composing: string | null = null;
function ComposerProbe() {
// eslint-disable-next-line react-hooks/globals -- a TEST probe: a module-level handle is how the test reads a hook's value from outside the tree without a wrapper that would change what is being measured.
composing = useComposerSession();
return null;
}
function mount(me: Partial<Me>, extra: React.ReactNode = null) {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
qc.setQueryData(qk.me(), {
id: 1,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null,
...me,
});
render(
<QueryClientProvider client={qc}>
<Toaster />
<DebugRecorder />
<ComposerProbe />
{extra}
</QueryClientProvider>,
);
return qc;
}
const pill = () => screen.queryByRole("group", { name: "recorder.pill.label" });
/** A dialog the test opens and closes while the pill is up: the pill moves into its toast host. */
function DialogToggle() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
{open && (
<Modal initialFocus="close" title="dialog" onClose={() => setOpen(false)}>
<p>inside</p>
</Modal>
)}
</>
);
}
describe("the recorder pill", () => {
it("shows while a session records, under the toasts and outside their live region", () => {
mount({ debug_session: session() });
const p = pill()!;
expect(p).not.toBeNull();
expect(p.closest("[data-toast-region]")).not.toBeNull();
expect(p.closest("[aria-live]")).toBeNull();
});
it("is not there for a session that ended or ran past its cap", () => {
mount({ debug_session: session({ until: new Date(Date.now() - 1000).toISOString() }) });
expect(pill()).toBeNull();
cleanup();
mount({ debug_session: session({ ended_at: new Date().toISOString() }) });
expect(pill()).toBeNull();
});
it("Stop sends what is waiting first, then stops, then opens the composer", async () => {
const qc = mount({ debug_session: session() });
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
await waitFor(() => expect(stopDebugSession).toHaveBeenCalledWith("0123456789abcdef"));
expect(order).toEqual(["flush", "stop"]);
await waitFor(() => expect(pill()).toBeNull());
expect(qc.getQueryData<Me>(qk.me())?.debug_session).toBeNull();
expect(composing).toBe("0123456789abcdef");
});
it("a step is one tagged event, numbered, and the focus comes back to Step", () => {
mount({ debug_session: session() });
const step = within(pill()!).getByRole("button", { name: "recorder.pill.step" });
fireEvent.click(step);
const input = screen.getByLabelText("recorder.pill.stepPrompt:1");
expect(document.activeElement).toBe(input);
fireEvent.change(input, { target: { value: "opened the player" } });
fireEvent.keyDown(input, { key: "Enter" });
const marked = events().filter((e) => e.event === "debug.step");
expect(marked.slice(-1)[0]?.attrs).toMatchObject({ n: 1, label: "opened the player" });
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
expect(document.activeElement).toBe(step);
expect(step.textContent).toContain("· 1");
});
it("Escape in the step prompt closes the prompt, not the dialog the pill sits in", () => {
const onClose = vi.fn();
mount(
{ debug_session: session() },
<Modal initialFocus="close" title="dialog" onClose={onClose}>
<p>inside</p>
</Modal>,
);
// The pill moved into the dialog's own toast host.
expect(pill()!.closest('[role="dialog"]')).not.toBeNull();
const marked = () => events().filter((e) => e.event === "debug.step").length;
const before = marked();
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
fireEvent.keyDown(screen.getByLabelText(/recorder.pill.stepPrompt/), { key: "Escape" });
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
expect(onClose).not.toHaveBeenCalled();
expect(marked()).toBe(before);
});
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
mount(
{ debug_session: session() },
<Modal initialFocus="close" title="dialog" onClose={() => {}}>
<p>inside</p>
</Modal>,
);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
mark.focus();
// The dialog's trap takes the Tab (jsdom has no layout, so it can only be seen taking it — the
// browser would otherwise walk out of the dialog into the page behind its scrim).
const unhandled = fireEvent.keyDown(mark, { key: "Tab" });
expect(unhandled).toBe(false);
});
it("the step prompt quiets the page's keys and leaves the Tab to the dialog it sits in", () => {
mount({ debug_session: session() });
expect(anyBlockingLayer()).toBe(false);
// The pill docks inside whatever dialog is open; stand one in for it, under the prompt.
const dialog = pushLayer();
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
// The prompt's field has the keyboard, so `j` and the `g` chords stay off the page behind...
expect(anyBlockingLayer()).toBe(true);
// ...while the dialog around it keeps its focus trap. The prompt cannot be that trap: its two
// controls are two of the dialog's many, and taking it left the rest of them untrapped, with
// Tab walking out into the page behind the scrim.
expect(anyBlockingLayerAbove(dialog)).toBe(false);
// And the quiet is the prompt's own doing, not the stand-in's: it outlasts the dialog.
removeLayer(dialog);
expect(anyBlockingLayer()).toBe(true);
});
it("the step prompt keeps what was typed when the pill moves to a dialog and back", () => {
mount({ debug_session: session() }, <DialogToggle />);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
fireEvent.change(screen.getByLabelText(/recorder.pill.stepPrompt/), {
target: { value: "opened the player" },
});
fireEvent.click(screen.getByText("toggle dialog"));
expect(pill()!.closest('[role="dialog"]')).not.toBeNull(); // moved: a new pill
expect((screen.getByLabelText(/recorder.pill.stepPrompt/) as HTMLInputElement).value).toBe(
"opened the player",
);
});
it("a Stop still running keeps its button busy after the pill moves", async () => {
let release = () => {};
hold.stop = new Promise((r) => (release = r));
mount({ debug_session: session() }, <DialogToggle />);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
await waitFor(() => expect(order).toContain("stop"));
fireEvent.click(screen.getByText("toggle dialog"));
const stop = within(pill()!).getByRole("button", { name: "recorder.pill.stop" });
expect(stop.getAttribute("aria-busy")).toBe("true");
release();
await waitFor(() => expect(pill()).toBeNull());
});
it("a Start or a Stop in another tab makes this one ask the server again", async () => {
const qc = mount({ debug_session: session() });
const invalidate = vi.spyOn(qc, "invalidateQueries");
const otherTab = new BroadcastChannel("siftlode.debugSession");
otherTab.postMessage("changed");
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.me() }));
otherTab.close();
});
it("offers a screenshot only where the browser can capture a tab", () => {
mount({ debug_session: session() });
// jsdom has no Screen Capture API — like iOS Safari.
expect(within(pill()!).queryByRole("button", { name: "recorder.pill.screenshot" })).toBeNull();
});
it("at its cap it says so and opens the composer", () => {
vi.useFakeTimers();
mount({ debug_session: session({ until: new Date(Date.now() + 5_000).toISOString() }) });
act(() => void vi.advanceTimersByTime(5_000));
expect(screen.getByText("recorder.pill.expired:60")).not.toBeNull();
expect(composing).toBe("0123456789abcdef");
});
});
describe("the start dialog", () => {
it("states the retention and the cap, and starts at the level chosen", async () => {
const qc = mount({});
act(() => openRecorderStart());
expect(screen.getByText(/recorder.start.kept:7/)).not.toBeNull();
expect(screen.getByText(/recorder.start.cap:60/)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.levels.TRACE" }));
expect(screen.getByText("recorder.start.what.TRACE")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: /recorder.start.confirm/ }));
await waitFor(() => expect(startDebugSession).toHaveBeenCalledWith("TRACE"));
await waitFor(() => expect(pill()).not.toBeNull());
expect(qc.getQueryData<Me>(qk.me())?.debug_session?.level).toBe("TRACE");
expect(screen.queryByText("recorder.start.title")).toBeNull();
});
});
describe("the past sessions a user goes back to check", () => {
function settings() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), {
id: 1,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null,
});
render(
<QueryClientProvider client={qc}>
<RecorderSettings />
</QueryClientProvider>,
);
}
it("does not call a half-delivered report sent", async () => {
// An issue that still owes its logs, or an edit that shows its screenshots, is not done. This
// list is where a user checks back, and a green "Sent" here is how one gets forgotten.
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
past.items = [
session({
...over,
id: "aaaaaaaaaaaaaaa1",
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["client.log"],
}),
session({
...over,
id: "aaaaaaaaaaaaaaa2",
issue_url: "https://git.example/o/r/issues/8",
images_pending: true,
}),
session({ ...over, id: "aaaaaaaaaaaaaaa3", issue_url: "https://git.example/o/r/issues/9" }),
];
settings();
expect(await screen.findAllByText("recorder.history.partial")).toHaveLength(2);
expect(screen.getAllByText("recorder.history.sent")).toHaveLength(1);
});
it("says a send is still running rather than naming what it has got to so far", async () => {
// This is the list a user opens TO CHECK on a send. Its two honest-looking words are both
// wrong for a running one: "draft" before it has an issue, "half sent" after.
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
past.items = [
session({
...over,
id: "bbbbbbbbbbbbbbb1",
sending: true,
draft: { title: "t", body: "b", excluded: [], form: {} },
}),
session({
...over,
id: "bbbbbbbbbbbbbbb2",
sending: true,
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["client.log"],
}),
];
settings();
expect(await screen.findAllByText("recorder.history.sending")).toHaveLength(2);
expect(screen.queryByText("recorder.history.draft")).toBeNull();
expect(screen.queryByText("recorder.history.partial")).toBeNull();
});
});
@@ -0,0 +1,414 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Flag, Square } from "lucide-react";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { canCaptureTab } from "../../lib/captureTab";
import {
closeRecorderStart,
onSessionChangeElsewhere,
openComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
useRecorderStartOpen,
useStepCount,
useStepPrompt,
} from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe";
import { Button } from "../../components/ui/Button";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
// The debug recorder: Start → reproduce → Stop.
//
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
// app shell, for a signed-in account.
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
function useElapsed(since: string): string {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/purity -- the clock is READ at render and the interval above only re-renders — a `now` held in state is the moment the component mounted, which is the defect DiagnosticsSettings records from prod UAT.
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
}
export default function DebugRecorder() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const { session } = useDebugRecording();
const startOpen = useRecorderStartOpen();
const composing = useComposerSession();
const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
// Started or stopped in another tab: this one asks the server what is in force now.
useEffect(
() => onSessionChangeElsewhere(() => void qc.invalidateQueries({ queryKey: qk.me() })),
[qc],
);
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
const until = session?.until;
const minutes = me?.diagnostics?.session_minutes;
useEffect(() => {
if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => {
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() });
}, wait);
return () => window.clearTimeout(timer);
}, [id, until, minutes, qc, t]);
return (
<>
{startOpen && <StartDialog />}
{composing && <IssueComposer id={composing} />}
</>
);
}
function StartDialog() {
const { t } = useTranslation();
const me = useMe();
const { start } = useDebugRecording();
const [level, setLevel] = useState<DebugSessionLevel>("DEBUG");
const days = me.diagnostics.retention_days;
const minutes = me.diagnostics.session_minutes;
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
return (
<Modal initialFocus="field" title={t("recorder.start.title")} onClose={closeRecorderStart}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
<div className="flex flex-col items-start gap-1.5">
<span className="text-sm text-muted">{t("recorder.start.level")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("recorder.start.level")}
options={levels.map((l) => ({
value: l,
label: t(`settings.diagnostics.levels.${l}`),
}))}
value={level}
onChange={setLevel}
/>
</div>
<div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
<p>{t(`recorder.start.what.${level}`)}</p>
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
<p className="text-muted">
{t("recorder.start.kept", { count: days })}{" "}
{t("recorder.start.cap", { count: minutes })}
</p>
</div>
<ModalFooter>
<Button variant="ghost" onClick={closeRecorderStart}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
loading={start.isPending}
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
onClick={() => start.mutate(level)}
>
{t("recorder.start.confirm")}
</Button>
</ModalFooter>
</div>
</Modal>
);
}
/** The pill: how long it has been recording, and the three things to do while it does. */
function RecorderPill() {
const { t } = useTranslation();
const { session, stop, stopping, shooting, markStep, screenshot } = useDebugRecording();
const [hidden, setHidden] = useState(false);
const stepOpen = useStepPrompt() !== null;
const stepButton = useRef<HTMLButtonElement>(null);
const steps = useStepCount(session?.id ?? "");
if (!session) return null;
return (
<div
role="group"
aria-label={t("recorder.pill.label")}
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 ${
hidden ? "invisible" : ""
}`}
>
<span
aria-hidden
className="w-2.5 h-2.5 shrink-0 rounded-full bg-danger ring-4 ring-danger/20 motion-safe:animate-pulse"
/>
<Elapsed since={session.started_at} />
<span className="chip hidden sm:inline-flex px-2 py-0.5 text-2xs">
{t(`settings.diagnostics.levels.${session.level}`)}
</span>
<span aria-hidden className="hidden sm:block w-px h-6 bg-border" />
<Button
ref={stepButton}
variant="outline-accent"
size="sm"
className="rounded-full"
icon={<Flag className="w-4 h-4" />}
label={t("recorder.pill.step")}
aria-expanded={stepOpen}
onClick={() => setStepPrompt(stepOpen ? null : "")}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.step")}
{steps ? ` · ${steps}` : ""}
</span>
</Button>
{canCaptureTab() && (
<Button
variant="outline"
size="sm"
className="rounded-full"
icon={<Camera className="w-4 h-4" />}
label={t("recorder.pill.screenshot")}
loading={shooting}
onClick={() => void screenshot(setHidden)}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.screenshot")}
{session.screenshots ? ` · ${session.screenshots}` : ""}
</span>
</Button>
)}
<Button
variant="danger"
size="sm"
className="rounded-full"
icon={<Square className="w-3.5 h-3.5 fill-current" />}
label={t("recorder.pill.stop")}
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
</Button>
{stepOpen && (
<StepPrompt
id={session.id}
onDone={(label) => {
if (label !== null) markStep(label);
setStepPrompt(null);
stepButton.current?.focus();
}}
/>
)}
</div>
);
}
function Elapsed({ since }: { since: string }) {
const { t } = useTranslation();
const time = useElapsed(since);
return (
// On a phone only the clock: the pill has to fit the content column beside the rail, and the
// red dot already says what it is.
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
<span className="hidden sm:inline">{t("recorder.pill.recording", { time })}</span>
<span className="sm:hidden">{time}</span>
</span>
);
}
/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */
export function RecorderSettings() {
return (
<div className="flex flex-col gap-4">
<RecorderControl />
<SessionHistory />
</div>
);
}
function RecorderControl() {
const { t } = useTranslation();
const { session, stop, stopping } = useDebugRecording();
if (!session)
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted max-w-prose">{t("recorder.settings.hint")}</p>
<Button
variant="outline"
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
onClick={openRecorderStart}
>
{t("recorder.settings.start")}
</Button>
</div>
);
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3 py-2 text-sm">
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-danger" aria-hidden />
<SettingsElapsed since={session.started_at} />
</span>
<Button
variant="danger-soft"
size="sm"
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
{t("recorder.settings.stop")}
</Button>
</div>
);
}
function SettingsElapsed({ since }: { since: string }) {
const { t } = useTranslation();
const time = useElapsed(since);
return <span className="tabular-nums">{t("recorder.settings.recording", { time })}</span>;
}
/** "What did you just do?" — optional; Enter marks the step, Escape leaves it unmarked. */
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
const { t } = useTranslation();
const n = useStepCount(id) + 1;
const label = useStepPrompt() ?? "";
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last. It
// OWNS the keyboard while it is up (an autofocused field does), so the page's own keys stay quiet.
// It does NOT take the Tab, though: it is a field inside whatever dialog the pill is docked in,
// not a dialog of its own, and the trap belongs to that one — it spans the prompt as it spans the
// rest of the toast host. Claiming it here switched the dialog's off and left every control
// OUTSIDE this little box untrapped.
useLayer(true, () => onDone(null), { trapsTab: false });
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
onDone(label);
}
};
return (
<div
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
>
<label htmlFor="debug-step-label" className="text-sm text-muted">
{t("recorder.pill.stepPrompt", { n })}
</label>
<Input
className="w-full"
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setStepPrompt(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted">
<span>{t("recorder.pill.stepHint")}</span>
<Button variant="primary" size="sm" onClick={() => onDone(label)}>
{t("recorder.pill.mark")}
</Button>
</div>
</div>
);
}
/** The five words a past session can read as. A union, not `string`, because `chipTone` below
* matches on these same keys: untyped, a rename in one of them left the other matching nothing
* and every chip untoned, with the gate green. */
type HistoryState =
| "recorder.history.sending"
| "recorder.history.draft"
| "recorder.history.unwritten"
| "recorder.history.partial"
| "recorder.history.sent";
/** What a past session reads as. An issue that still owes the tracker its attachments, or an edit
* that shows its screenshots, is NOT "Sent": this list is where a user goes back to check, and a
* green chip there is how a half-delivered report gets forgotten. */
function historyState(s: DebugSession): HistoryState {
// A send under way outranks every other word here: this is the list a user comes back to in
// order to check on one, and both "draft" (no issue yet) and "half sent" (the steps it has
// already committed) would describe a send that STOPPED.
if (s.sending) return "recorder.history.sending";
if (!s.issue_url) return s.draft ? "recorder.history.draft" : "recorder.history.unwritten";
return s.pending_files.length > 0 || s.images_pending
? "recorder.history.partial"
: "recorder.history.sent";
}
/** The colour that reinforces that word. Exhaustive over `HistoryState`, so a key renamed in
* `historyState` is a compile error here rather than a chip that quietly loses its tone. */
const chipTone = (s: DebugSession): string => {
switch (historyState(s)) {
case "recorder.history.sent":
return "text-success";
case "recorder.history.sending":
return "text-accent";
case "recorder.history.partial":
return "text-warn";
case "recorder.history.draft":
case "recorder.history.unwritten":
return "";
}
};
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data)
return (
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
{t("recorder.history.failed")}
</StateMessage>
);
if (!q.data) return <LoadingState />;
return (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-muted">{t("recorder.history.title")}</div>
{items.length === 0 ? (
<p className="text-sm text-muted">{t("recorder.history.empty")}</p>
) : (
<ul className="flex flex-col divide-y divide-border/60 rounded-xl border border-border">
{items.map((s) => (
<li
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">{formatDateTimeSec(s.started_at, lang)}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
<span className={`chip px-2 py-0.5 text-2xs ${chipTone(s)}`}>
{t(historyState(s))}
</span>
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
{t("recorder.history.open")}
</Button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import { navItemAttrs } from "../../lib/listNav";
import { Pressable } from "../../components/ui/Pressable";
// One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
// its lines through this): its cells on one line, and the row opens in place to its details. One frame
// for all of them, so a row opens, announces itself and takes the keyboard the same on every tab.
// Inside a NavGrid the keyboard walks the rows (`j`/`k`) and Enter opens one.
export default function HistoryRow({
cells,
grid,
navIndex,
navText,
open,
onToggle,
title,
line = false,
children,
}: {
/** The row's cells, laid out by `grid`. */
cells: ReactNode;
/** The grid's column template classes (phone first, then wider screens). */
grid: string;
navIndex: number;
/** What the keyboard's type-ahead matches. */
navText: string;
open: boolean;
onToggle: () => void;
/** The row button's tooltip — the full date. */
title?: string;
/** A log line: tighter, its cells on one text baseline, its size the (monospace) list's own. */
line?: boolean;
/** The detail shown while the row is open. */
children: ReactNode;
}) {
return (
<div
className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, navText, { inset: true })}
>
<Pressable
selectable
layer={!open}
data-nav-open
aria-expanded={open}
onClick={onToggle}
title={title}
className={`grid w-full gap-x-2 px-3 text-left touch-target pointer-coarse:content-center ${
line ? "items-baseline py-1" : "items-center py-1.5 text-xs"
} ${grid} ${open ? "bg-accent/10" : ""}`}
>
{cells}
</Pressable>
{open && (
<div className={`bg-accent/5 px-3 py-2 text-muted ${line ? "" : "text-xs"}`}>
{children}
</div>
)}
</div>
);
}
@@ -0,0 +1,597 @@
// @vitest-environment jsdom
//
// The issue composer. It drafts the steps from the session, saves as it goes (the rendered
// text included — the export is built from what it SAVED), reopens as it was left, takes a log line
// out of the evidence in the review, adds a pasted image, and a sent session is read-only. i18n is a
// key-passthrough with its params; the API is stubbed.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugDraft, DebugSession, ServerLogRow } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key,
i18n: { language: "en" },
}),
};
});
const api = vi.hoisted(() => ({
session: null as unknown as DebugSession,
rows: [] as ServerLogRow[],
saved: [] as DebugDraft[],
uploaded: [] as Blob[],
failSession: false,
failShots: false,
sent: 0,
previewed: [] as string[],
// What happened, in order: a save is "save" when asked and "saved" when it answered.
order: [] as string[],
holdSend: null as Promise<void> | null,
// The server's half-failure: the issue is committed, then an upload dies and the call 422s.
failSendAfterCreate: false,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: {
...actual.api,
debugSession: () =>
api.failSession ? Promise.reject(new Error("down")) : Promise.resolve(api.session),
debugSessionEvents: () => Promise.resolve({ items: api.rows, total: api.rows.length }),
debugScreenshots: () =>
api.failShots ? Promise.reject(new Error("down")) : Promise.resolve({ items: [] }),
saveDebugDraft: (_id: string, draft: DebugDraft) => {
api.saved.push(draft);
api.order.push("save");
// Answers a task later, so a caller that does not wait for it is seen not waiting.
return new Promise((resolve) =>
setTimeout(() => {
api.order.push("saved");
resolve({ ok: true, session: { ...api.session, draft } });
}, 0),
);
},
addDebugScreenshot: (_id: string, image: Blob) => {
api.uploaded.push(image);
return Promise.resolve({
ok: true,
screenshot: { id: 1, mime: image.type, size: 4, created_at: "" },
});
},
debugExport: () => {
api.order.push("export");
return Promise.resolve({ blob: new Blob(["zip"]), name: "siftlode-debug.zip" });
},
sendDebugIssue: async () => {
api.order.push("send");
if (api.holdSend) await api.holdSend;
api.sent += 1;
api.session = {
...api.session,
issue_url: "https://git.example/o/r/issues/7",
pending_files: api.failSendAfterCreate ? ["server.log"] : [],
// What the server does on the way through: a send that got there shows the images too,
// so the banner (and the Retry in it) goes. Leaving this true made the retry's own
// banner outlive its success, which is not a state the server can produce.
images_pending: false,
};
if (api.failSendAfterCreate) throw new Error("issues_unreachable");
return { ok: true as const, session: api.session };
},
previewDebugIssue: (_id: string, text: string) => {
api.previewed.push(text);
return Promise.resolve({ ok: true, html: "<h3>Steps</h3><script>alert(1)</script>" });
},
},
};
});
import IssueComposer from "./IssueComposer";
const SID = "0123456789abcdef";
let n = 1;
const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
id: n++,
ts: "2026-09-19T14:05:31.000Z",
level: "INFO",
source: "web",
logger: "web",
event: "x.y",
msg: "m",
attrs: null,
exc: null,
request_id: null,
user_id: 1,
job_id: null,
run_id: null,
debug_session_id: SID,
version: null,
...over,
});
const baseSession = (over: Partial<DebugSession> = {}): DebugSession => ({
id: SID,
level: "DEBUG",
started_at: "2026-09-19T14:02:00.000Z",
until: "2026-09-19T15:02:00.000Z",
ended_at: "2026-09-19T14:16:00.000Z",
ended_reason: "stopped",
issue_url: null,
pending_files: [],
images_pending: false,
sending: false,
screenshots: 0,
draft: null,
...over,
});
beforeEach(() => {
api.session = baseSession();
api.rows = [
row({ event: "debug.step", msg: "resume", attrs: { n: 1, label: "Resume" } }),
row({ event: "ui.click", msg: "private click" }),
row({ source: "api", event: "http.request", msg: "GET /api/x 200" }),
];
api.saved = [];
api.uploaded = [];
api.failSession = false;
api.failShots = false;
api.order = [];
api.holdSend = null;
api.failSendAfterCreate = false;
api.sent = 0;
api.previewed = [];
tracker = null;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
let tracker: { repo: string } | null = null;
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, issues: tracker });
render(
<QueryClientProvider client={qc}>
<IssueComposer id={SID} />
</QueryClientProvider>,
);
}
/** jsdom lays nothing out, so every element reads as hidden to the dialog's focus rules. */
const layOut = () =>
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
const lastSaved = () => api.saved[api.saved.length - 1]!;
const title = () => screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
/** A button once it can be pressed: `loading` holds it while the draft's first save settles. */
async function ready(name: string): Promise<HTMLButtonElement> {
const button = (await screen.findByRole("button", { name })) as HTMLButtonElement;
await waitFor(() => expect(button.disabled).toBe(false));
return button;
}
describe("the issue composer", () => {
it("drafts the steps from the session and saves the rendered text after a pause", async () => {
mount();
const first = await screen.findByLabelText("recorder.composer.stepLabel:1");
expect((first as HTMLInputElement).value).toBe("Resume");
fireEvent.change(screen.getByLabelText("recorder.composer.fieldTitle"), {
target: { value: "Spinner stays" },
});
await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 });
expect(lastSaved().title).toBe("Spinner stays");
expect(lastSaved().body).toContain("1. Resume");
expect(lastSaved().body).toContain("`client.log` — recorder.issue.clientLog:2");
});
it("reopens a saved draft as it was left", async () => {
api.session = baseSession({
draft: {
title: "Saved title",
body: "",
excluded: [],
form: {
title: "Saved title",
description: "kept",
steps: [{ id: "m1", kind: "manual", text: "typed" }],
excluded: [],
},
},
});
mount();
expect(
((await screen.findByLabelText("recorder.composer.fieldTitle")) as HTMLInputElement).value,
).toBe("Saved title");
expect((screen.getByLabelText("recorder.composer.stepLabel:1") as HTMLInputElement).value).toBe(
"typed",
);
});
it("takes an unticked line out of the evidence", async () => {
mount();
fireEvent.click(
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
);
fireEvent.click(
screen.getByRole("checkbox", { name: "recorder.composer.keepRow:ui.click,private click" }),
);
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.reviewDone" }));
expect(screen.getByText(/recorder.composer.linesKept:1,2/)).not.toBeNull();
await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 });
const clickId = api.rows[1]!.id;
expect(lastSaved().excluded).toEqual([clickId]);
expect(lastSaved().body).toContain("recorder.issue.clientLog:1");
});
it("adds a pasted image, and nothing else that was pasted", async () => {
mount();
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
const text = new File(["hi"], "note.txt", { type: "text/plain" });
fireEvent.paste(zone, { clipboardData: { files: [png, text] } });
await waitFor(() => expect(api.uploaded).toHaveLength(1));
expect(api.uploaded[0]!.type).toBe("image/png");
});
it("takes a dropped or picked image, and nothing else", async () => {
mount();
const zone = (await screen.findByText(/recorder.composer.drop/)).closest("div")!;
const png = new File([new Uint8Array([137, 80, 78, 71])], "a.png", { type: "image/png" });
const pdf = new File(["%PDF"], "a.pdf", { type: "application/pdf" });
fireEvent.drop(zone, { dataTransfer: { files: [pdf, png] } });
await waitFor(() => expect(api.uploaded).toHaveLength(1));
expect(api.uploaded[0]!.type).toBe("image/png");
});
it("takes an image pasted while typing, anywhere in the composer", async () => {
mount();
await screen.findByText("recorder.composer.attachments");
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
fireEvent.paste(title(), { clipboardData: { files: [png] } });
await waitFor(() => expect(api.uploaded).toHaveLength(1));
});
it("saves an edit still waiting when it is closed", async () => {
mount();
await ready("recorder.composer.export");
fireEvent.change(title(), { target: { value: "Typed and closed" } });
cleanup(); // closed well inside the pause
await waitFor(() => expect(api.saved).toHaveLength(1));
expect(lastSaved().title).toBe("Typed and closed");
});
it("does not call a newer edit saved", async () => {
mount();
await ready("recorder.composer.export");
fireEvent.change(title(), { target: { value: "one" } });
expect(
await screen.findByText("recorder.composer.saved", {}, { timeout: 2000 }),
).not.toBeNull();
fireEvent.change(title(), { target: { value: "two" } });
expect(screen.queryByText("recorder.composer.saved")).toBeNull();
});
it("says so when the screenshots cannot be read, and still counts them from the session", async () => {
api.failShots = true;
api.session = baseSession({ screenshots: 2 });
mount();
expect(await screen.findByText("recorder.composer.shotsFailed")).not.toBeNull();
// Everything still works: the images are the one thing that could not be listed.
expect(screen.queryByText("recorder.composer.attachments")).not.toBeNull();
expect(
(screen.getByRole("button", { name: "recorder.composer.export" }) as HTMLButtonElement)
.disabled,
).toBe(false);
// Including the control standing right beside the failed list: more can still be added.
expect(screen.queryByText("recorder.composer.pick")).not.toBeNull();
// The draft is still saved — and says two screenshots, the server's own count, not none.
fireEvent.change(title(), { target: { value: "x" } });
await waitFor(() => expect(api.saved).toHaveLength(1), { timeout: 2000 });
expect(lastSaved().body).toContain("recorder.issue.screenshots:2");
});
it("says a send is under way instead of calling it half-failed, and holds the draft", async () => {
// Every step of a send commits its progress, so a reload or a second tab reads exactly the
// shape of a send that STOPPED. Only the server can tell the two apart — and it does.
api.session = baseSession({
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["server.log", "screenshot-1.png"],
sending: true,
});
mount();
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
expect(screen.queryByText(/recorder.composer.partial/)).toBeNull();
// No retry to press: it would only be refused while the send it invites is still running.
expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull();
fireEvent.change(title(), { target: { value: "typed while it went out" } });
await new Promise((r) => setTimeout(r, 1000));
expect(api.saved).toHaveLength(0);
});
it("holds the draft through a first send, before there is an issue to point at", async () => {
tracker = { repo: "o/r" };
// A send with no issue_url yet is what the read-only lock is for: an edit made mid-flight
// would be saved over the very body the send is at that moment uploading, and the issue that
// arrives would be neither draft. The half-sent case above is locked by its link instead.
api.session = baseSession({ sending: true });
mount();
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
fireEvent.change(title(), { target: { value: "typed mid-flight" } });
await new Promise((r) => setTimeout(r, 1000));
expect(api.saved).toHaveLength(0);
// Nor can a screenshot join what is already on its way up.
expect(screen.queryByText("recorder.composer.pick")).toBeNull();
// And the button that would start a SECOND send is out — the server would only refuse it.
expect(
(screen.getByRole("button", { name: "recorder.composer.send" }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("announces the half-sent banner instead of only drawing it", async () => {
// A screen-reader user hears the failure toast — "couldn't send the report" — and nothing else.
// WHICH attachments are missing, and that a Retry now stands beside them, live only in this
// banner, which is inserted already carrying its first message. A live region created together
// with its content is unreliably announced (the rule ui/Toaster.tsx states and follows), so the
// announcement has to come from a ROLE, which announces on insertion.
api.session = baseSession({
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["server.log"],
});
mount();
const said = await screen.findByText(/recorder\.composer\.partial/);
const announced = said.closest('[role="status"]');
expect(announced).not.toBeNull();
// ...and the Retry is inside what gets read out, not left behind silently beside it.
expect(announced!.querySelector("button")).not.toBeNull();
});
it("hands the focus on when a send half-fails, not only when it works", async () => {
tracker = { repo: "o/r" };
// The issue is created and an upload then dies, so the call FAILS with the link already
// committed. The refetch brings the link back, `sent` flips, and "Create issue" — the control
// holding the focus — unmounts under it. Without a hand-off the focus lands on <body> in the
// middle of an open dialog: a screen reader loses its place and the Retry that just appeared
// is several Tabs away, past the dialog's own ✕.
api.failSendAfterCreate = true;
mount();
const send = (await ready("recorder.composer.send")) as HTMLButtonElement;
send.focus();
expect(document.activeElement).toBe(send);
fireEvent.click(send);
await waitFor(() => expect(api.sent).toBe(1));
const retry = await screen.findByRole("button", { name: "recorder.composer.retry" });
await waitFor(() => expect(document.activeElement).toBe(retry));
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
});
it("still builds the zip while a send holds the draft, instead of saving into a refusal", async () => {
tracker = { repo: "o/r" };
// The zip is the only way out of a send that is stuck — a claim left by a dead request stands
// for ten minutes. Its pre-save is pointless here (the draft is frozen, and the server would
// 409 `issue_sending` and take the export down with it), and unnecessary: `GET /export` has no
// send guard and builds from the saved draft, which is exactly what the send is uploading.
api.session = baseSession({ sending: true });
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
click.mockImplementation(() => void api.order.push("click"));
const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL };
URL.createObjectURL = () => "blob:zip";
URL.revokeObjectURL = () => {};
try {
mount();
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
fireEvent.click(await ready("recorder.composer.export"));
await waitFor(() => expect(click).toHaveBeenCalled());
expect(api.order).toEqual(["export", "click"]);
expect(api.saved).toHaveLength(0);
} finally {
click.mockRestore();
URL.createObjectURL = real.make;
URL.revokeObjectURL = real.revoke;
}
});
it("asks to show the images, not to send what is already there", async () => {
tracker = { repo: "o/r" };
api.session = baseSession({
issue_url: "https://git.example/o/r/issues/7",
images_pending: true,
});
mount();
await screen.findByText("recorder.composer.partialImages");
// Nothing is missing from the issue here — only its text has still to show what it carries.
expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retryImages" }));
await waitFor(() => expect(api.sent).toBe(1));
// The button just pressed is gone with its banner: the focus goes to what takes its place,
// not to <body> in the middle of an open dialog.
await waitFor(() =>
expect(document.activeElement?.getAttribute("href")).toBe("https://git.example/o/r/issues/7"),
);
});
it("leaves an image pasted into a dialog opened over it alone", async () => {
mount();
await screen.findByText("recorder.composer.attachments");
const above = document.createElement("div");
above.setAttribute("aria-modal", "true");
const field = document.createElement("input");
above.appendChild(field);
document.body.appendChild(above);
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
fireEvent.paste(field, { clipboardData: { files: [png] } });
await new Promise((r) => setTimeout(r, 50));
expect(api.uploaded).toHaveLength(0);
above.remove();
});
it("moves the focus to what stays when a control goes", async () => {
mount();
fireEvent.click(
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
);
const done = screen.getByRole("button", { name: "recorder.composer.reviewDone" });
expect(document.activeElement).toBe(done);
fireEvent.click(done);
expect(document.activeElement).toBe(
screen.getAllByRole("button", { name: "recorder.composer.review" })[0],
);
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.removeStep:1" }));
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "recorder.composer.addStep" }),
);
});
it("leaves a paste without an image to the field it was typed into", async () => {
mount();
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
const pasted = fireEvent.paste(zone, { clipboardData: { files: [] } });
expect(pasted).toBe(true); // not prevented
expect(api.uploaded).toHaveLength(0);
});
it("saves before it asks for the zip, and hands it to the browser", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
click.mockImplementation(() => void api.order.push("click"));
const urls = { made: 0, revoked: 0 };
// jsdom implements neither, so there is nothing to spy on: they are DEFINED here — and put
// back afterwards, or every later test in this file would keep running against them.
const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL };
URL.createObjectURL = () => {
urls.made += 1;
return "blob:zip";
};
URL.revokeObjectURL = () => void (urls.revoked += 1);
mount();
fireEvent.click(await ready("recorder.composer.export"));
await waitFor(() => expect(click).toHaveBeenCalled());
// A request, not a bare navigation: a refusal (the export's own pace) is shown like any other.
expect(api.order).toEqual(["save", "saved", "export", "click"]);
expect([urls.made, urls.revoked]).toEqual([1, 1]);
click.mockRestore();
URL.createObjectURL = real.make;
URL.revokeObjectURL = real.revoke;
});
it("a sent session is read-only and links its issue", async () => {
api.session = baseSession({ issue_url: "https://git.example/o/r/issues/7" });
mount();
const link = await screen.findByRole("link", { name: /recorder.composer.openIssue/ });
expect(link.getAttribute("href")).toBe("https://git.example/o/r/issues/7");
const title = screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
expect(title.readOnly).toBe(true);
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
});
it("opens on the title, not on the Edit / Preview switch above it", async () => {
// The form arrives after the dialog opens; its first control is the switch, and the focus
// landed there — typing went nowhere.
layOut();
mount();
await waitFor(() => expect(document.activeElement).toBe(title()));
});
it("moves to the title once a retried load brings the form", async () => {
layOut();
api.failSession = true;
mount();
const message = await screen.findByText("recorder.composer.loadFailed");
api.failSession = false;
fireEvent.click(within(message.closest('[role="alert"]')!).getByRole("button"));
await waitFor(() => expect(document.activeElement).toBe(title()));
});
it("says so when the session cannot be loaded, with a retry", async () => {
api.failSession = true;
mount();
const message = await screen.findByText("recorder.composer.loadFailed");
expect(within(message.closest('[role="alert"]')!).getByRole("button")).not.toBeNull();
});
it("offers the tracker only where one is set up, and sends the saved draft", async () => {
mount();
await screen.findByRole("button", { name: "recorder.composer.export" });
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
cleanup();
tracker = { repo: "o/r" };
mount();
expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull();
fireEvent.click(await ready("recorder.composer.send"));
await waitFor(() => expect(api.sent).toBe(1));
// Saved, and answered, first: the issue is built from what was saved.
expect(api.order).toEqual(["save", "saved", "send"]);
expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull();
});
it("holds the draft while it is being sent", async () => {
tracker = { repo: "o/r" };
let release = () => {};
api.holdSend = new Promise((r) => (release = r));
mount();
fireEvent.click(await ready("recorder.composer.send"));
await waitFor(() => expect(api.order).toContain("send"));
// What is typed now would not reach the issue: the fields do not take it.
expect(title().readOnly).toBe(true);
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
release();
await waitFor(() => expect(api.sent).toBe(1));
});
it("finishes a send that stopped half-way, and says what is missing", async () => {
tracker = { repo: "o/r" };
api.session = baseSession({
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["server.log"],
});
mount();
expect(await screen.findByText("recorder.composer.partial:server.log")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retry" }));
await waitFor(() => expect(api.sent).toBe(1));
expect(api.saved).toHaveLength(0); // sent: nothing to save, only the rest to send
});
it("returns the focus to a reviewed log once, not on every way back from the preview", async () => {
mount();
fireEvent.click(
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
);
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.reviewDone" }));
const review = screen.getAllByRole("button", { name: "recorder.composer.review" })[0];
expect(document.activeElement).toBe(review);
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.preview" }));
const edit = screen.getByRole("button", { name: "recorder.composer.edit" });
edit.focus();
fireEvent.click(edit);
await screen.findAllByRole("button", { name: "recorder.composer.review" });
expect(document.activeElement).toBe(edit); // the toggle keeps it; the log's button does not take it
});
it("previews through the tracker in a frame where no script runs", async () => {
tracker = { repo: "o/r" };
mount();
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
const frame = (await screen.findByTitle("recorder.composer.previewTitle")) as HTMLIFrameElement;
expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts");
expect(frame.getAttribute("srcdoc")).toContain("<h3>Steps</h3>");
expect(api.previewed[0]).toContain("1. Resume");
});
it("previews the zip's own text where there is no tracker", async () => {
mount();
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
expect(screen.getByText(/1\. Resume/).tagName).toBe("PRE");
expect(api.previewed).toHaveLength(0);
});
});
@@ -0,0 +1,862 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import {
draftSteps,
environment,
renderIssue,
type DraftForm,
type DraftStep,
type IssueText,
} from "../../lib/issueDraft";
import { moduleDef } from "../../lib/modules";
import type { Page } from "../../lib/urlState";
import { qk } from "../../lib/queryKeys";
import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Textarea, Input } from "../../components/ui/Input";
import { Checkbox } from "../../components/ui/NativeControls";
import { SURFACE } from "../../components/ui/surfaces";
import RemoteImage from "../../components/ui/RemoteImage";
// The issue composer: one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
// each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick
// to add more — an image pasted anywhere in it is taken). It saves as it goes, and what is still
// waiting to be saved when it closes is saved then, so closing it loses nothing: Settings →
// Diagnosztika reopens any past session. The export and the issue are built from what it SAVED.
const SAVE_DELAY_MS = 800;
type Source = "web" | "server";
const sourceOf = (row: ServerLogRow): Source => (row.source === "web" ? "web" : "server");
export default function IssueComposer({ id }: { id: string }) {
const { t } = useTranslation();
const sessionQ = useQuery({ queryKey: qk.debugSession(id), queryFn: () => api.debugSession(id) });
const eventsQ = useQuery({
queryKey: qk.debugSessionEvents(id),
queryFn: () => api.debugSessionEvents(id),
});
const failed = sessionQ.isError || eventsQ.isError;
// Closed after a Stop, the focus has nowhere to go back to — the Stop button went with the pill —
// and would be left on <body>. The page the user is on is the next best place. After the dialog's
// own restore (a timeout: its cleanup runs after this one), and only when that had nothing.
useEffect(
() => () => {
window.setTimeout(() => {
if (focusIsUnclaimed())
document.querySelector<HTMLElement>('nav [aria-current="page"]')?.focus();
}, 0);
},
[],
);
return (
<Modal
initialFocus="field"
title={t("recorder.composer.title")}
onClose={closeComposer}
maxWidth="max-w-5xl"
>
{failed && !(sessionQ.data && eventsQ.data) ? (
<StateMessage
tone="error"
onRetry={() => {
void sessionQ.refetch();
void eventsQ.refetch();
}}
>
{t("recorder.composer.loadFailed")}
</StateMessage>
) : !sessionQ.data || !eventsQ.data ? (
<LoadingState />
) : (
<ComposerBody
session={sessionQ.data}
rows={eventsQ.data.items}
total={eventsQ.data.total}
/>
)}
</Modal>
);
}
/** The saved form if it has the shape this composer writes; otherwise null (an older or foreign
* draft starts over from the session rather than half-applying). */
function savedForm(session: DebugSession): DraftForm | null {
const form = session.draft?.form as Partial<DraftForm> | undefined;
if (!form || typeof form.title !== "string" || !Array.isArray(form.steps)) return null;
return {
title: form.title,
description: typeof form.description === "string" ? form.description : "",
steps: form.steps as DraftStep[],
excluded: Array.isArray(form.excluded) ? (form.excluded as number[]) : [],
};
}
function ComposerBody({
session,
rows,
total,
}: {
session: DebugSession;
rows: ServerLogRow[];
total: number;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const pageName = (page: string) => {
const def = moduleDef(page as Page);
return def ? t(def.labelKey) : page;
};
const [form, setForm] = useState<DraftForm>(
() =>
savedForm(session) ?? {
title: "",
description: "",
steps: draftSteps(rows, pageName, logClock),
excluded: [],
},
);
const [reviewing, setReviewing] = useState<Source | null>(null);
const issueLink = useRef<HTMLAnchorElement>(null);
const retryButton = useRef<HTMLButtonElement>(null);
// The log whose review just closed: its Review button takes the focus back when the attachments
// mount again (they unmount while a review is open).
const [reviewed, setReviewed] = useState<Source | null>(null);
const sent = session.issue_url !== null;
const shotsQ = useQuery({
queryKey: qk.debugScreenshots(session.id),
queryFn: () => api.debugScreenshots(session.id),
});
const shots = shotsQ.data?.items ?? [];
// How many the ISSUE will carry. From the list while it is loaded, else from the session itself
// (the server's own count) — a failed read must not quietly become "no screenshots".
const shotCount = shotsQ.data ? shots.length : session.screenshots;
const excluded = useMemo(() => new Set(form.excluded), [form.excluded]);
const kept = (source: Source) =>
rows.filter((r) => sourceOf(r) === source && !excluded.has(r.id)).length;
const env = useMemo(() => environment(i18n.language), [i18n.language]);
const text: IssueText = {
steps: t("recorder.issue.steps"),
environment: t("recorder.issue.environment"),
envKeys: Object.fromEntries(env.map(([k]) => [k, t(`recorder.composer.env.${k}`)])),
screenshot: t("recorder.issue.screenshot"),
attachments: t("recorder.issue.attachments"),
clientLog: (count) => t("recorder.issue.clientLog", { count }),
serverLog: (count) => t("recorder.issue.serverLog", { count }),
screenshots: (count) => t("recorder.issue.screenshots", { count }),
truncated:
total > rows.length
? t("recorder.composer.truncated", { kept: rows.length, total })
: undefined,
};
const body = renderIssue(
form,
env,
{ client: kept("web"), server: kept("server"), screenshots: shotCount },
text,
);
// Saves as it goes — after the typing pauses, not on every key. `edits` counts the edits, so a
// save that finishes after a newer edit does not call that one saved.
const edits = useRef(0);
const [unsaved, setUnsaved] = useState(false);
const save = useMutation({
mutationFn: async () => {
const upTo = edits.current;
const r = await api.saveDebugDraft(session.id, {
title: form.title.trim(),
body,
excluded: form.excluded,
form: form as unknown as Record<string, unknown>,
});
return { ...r, upTo };
},
meta: { failTitle: "recorder.composer.saveFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
if (r.upTo === edits.current) setUnsaved(false);
},
});
const edit = (next: (f: DraftForm) => DraftForm) => {
edits.current += 1;
setUnsaved(true);
setForm(next);
};
// The issue, like the export, is built from the SAVED draft: save what is on screen first.
// A send that stopped half-way is finished by the same call, which does only what is missing.
// While it runs the draft is held as it was saved: nothing typed then would reach the issue.
const target = useMe()?.issues ?? null;
const pending = session.pending_files;
// A send is running — this tab's or another's. Its progress is committed step by step, so the
// half-sent shape below is also what a LIVE send looks like; only the server can tell them apart.
const sending = session.sending;
const send = useMutation({
mutationFn: async () => {
if (!sent) await save.mutateAsync();
return api.sendDebugIssue(session.id);
},
meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
notify({ level: "success", message: t("recorder.composer.sentOk") });
},
// A failure half-way still created the issue: show what the server now has.
onSettled: async () => {
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
await qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
// Whichever way it went, the button that was just pressed is gone: a success unmounts the
// Retry's banner, and a half-failure unmounts Create issue (the refetch above brings back the
// issue_url it created before it died). Either leaves the focus on <body> inside an open
// dialog, so hand it on — to whatever now stands where the pressed control was. AFTER the
// refetch, not in onSuccess/onError: before it lands, neither target is rendered yet.
window.setTimeout(() => (retryButton.current ?? issueLink.current)?.focus(), 0);
},
});
const locked = sent || send.isPending || sending;
const saveNow = useEffectEvent(() => save.mutate());
useEffect(() => {
if (locked || !unsaved) return;
const timer = window.setTimeout(() => saveNow(), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [body, form.title, locked, unsaved]);
// Closed before the typing paused: save what is still waiting, now.
const flush = useEffectEvent(() => {
if (unsaved && !locked) save.mutate();
});
useEffect(() => () => flush(), []);
// Screenshots are added here, not in the attachments column: an image pasted ANYWHERE in the
// composer is taken — while typing the description is exactly when one is pasted.
const addShot = useMutation({
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: () => refreshShots(qc, session.id),
});
const addImage = useEffectEvent((image: Blob) => addShot.mutate(image));
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
if (locked) return;
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
// And only OUR dialog's: something opened over the composer (the command palette) keeps its own.
const onPaste = (e: ClipboardEvent) => {
const dialog = e.target instanceof Element ? e.target.closest('[aria-modal="true"]') : null;
if (dialog && !dialog.contains(root.current)) return;
const images = [...(e.clipboardData?.files ?? [])].filter(isImage);
if (!images.length) return;
e.preventDefault();
for (const image of images) addImage(image);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [locked]);
const [mode, setMode] = useState<"edit" | "preview">("edit");
// The export is built from the SAVED draft: save what is on screen first — except when the
// draft is already frozen (`locked`), where the save is both pointless and refused. While a send
// holds the claim the server 409s `issue_sending`, and that refusal would take the zip with it:
// `GET /export` has no send guard and would have served exactly what the send is uploading, so
// the one tracker-free way out of a stuck send used to fail at the last step.
const exportZip = useMutation({
mutationFn: async () => {
if (!locked) await save.mutateAsync();
return api.debugExport(session.id);
},
meta: { failTitle: "recorder.composer.exportFailed" },
onSuccess: ({ blob, name }) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
},
});
const started = new Date(session.started_at);
const minutes = Math.max(
1,
Math.round(
(Date.parse(session.ended_at ?? new Date().toISOString()) - started.getTime()) / 60_000,
),
);
return (
<div ref={root} className="flex flex-col gap-4">
<p className="text-sm text-muted">
{t("recorder.composer.meta", {
start: formatDateTimeSec(started.toISOString(), lang),
minutes: formatUnit(minutes, "minute", lang),
level: t(`settings.diagnostics.levels.${session.level}`),
})}
</p>
{(sending || (sent && (pending.length > 0 || session.images_pending))) && (
<div
className={`flex flex-wrap items-center justify-between gap-2 rounded-xl border px-3 py-2 text-sm ${
sending ? "border-accent/40 bg-accent/10" : "border-warn/40 bg-warn/10"
}`}
// `role="status"` rather than `aria-live`: this box is mounted TOGETHER with its first
// message (a half-failed send inserts it already populated), and a live region created
// with its content is unreliably announced — the rule ui/Toaster.tsx states and follows.
// A role announces on insertion, and its implied atomic re-read is right for one line.
role="status"
>
<span>
{sending
? t("recorder.composer.sendingNow")
: pending.length > 0
? t("recorder.composer.partial", { files: pending.join(", ") })
: t("recorder.composer.partialImages")}
</span>
{/* Nothing to retry while it is still going — the button would only be refused. */}
{!sending && (
<Button
ref={retryButton}
variant="outline"
size="sm"
loading={send.isPending}
onClick={() => send.mutate()}
>
{t(pending.length > 0 ? "recorder.composer.retry" : "recorder.composer.retryImages")}
</Button>
)}
</div>
)}
{sent && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-success/40 bg-success/10 px-3 py-2 text-sm">
<span>{t("recorder.composer.sent")}</span>
<a
ref={issueLink}
href={session.issue_url!}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline"
>
{t("recorder.composer.openIssue")}
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
</a>
</div>
)}
{!reviewing && (
<SegmentedToggle
size="md"
className="self-start"
ariaLabel={t("recorder.composer.previewTitle")}
options={[
{ value: "edit", label: t("recorder.composer.edit") },
{ value: "preview", label: t("recorder.composer.preview") },
]}
value={mode}
onChange={setMode}
/>
)}
{mode === "preview" && !reviewing ? (
<IssuePreview
sessionId={session.id}
text={`# ${form.title.trim() || "…"}\n\n${body}`}
viaTracker={!!target}
/>
) : reviewing ? (
<ReviewList
rows={rows.filter((r) => sourceOf(r) === reviewing)}
name={t(
reviewing === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog",
)}
excluded={excluded}
readOnly={locked}
onToggle={(rowId) =>
edit((f) => ({
...f,
excluded: f.excluded.includes(rowId)
? f.excluded.filter((x) => x !== rowId)
: [...f.excluded, rowId],
}))
}
onDone={() => {
setReviewed(reviewing);
setReviewing(null);
}}
/>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<DraftFields form={form} edit={edit} env={env} readOnly={locked} />
<Attachments
session={session}
shotsQ={shotsQ}
counts={{
web: [kept("web"), rows.filter((r) => sourceOf(r) === "web").length],
server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length],
}}
readOnly={locked}
adding={addShot.isPending}
onAdd={(files) => {
for (const file of files) if (isImage(file)) addShot.mutate(file);
}}
onReview={setReviewing}
reviewed={reviewed}
setReviewed={setReviewed}
/>
</div>
)}
<ModalFooter>
<span className="mr-auto flex flex-col text-xs text-muted">
{target && <span>{t("recorder.composer.target", { repo: target.repo })}</span>}
<span aria-live="polite">
{send.isPending
? t("recorder.composer.sending")
: save.isPending
? t("recorder.composer.saving")
: save.isSuccess && !unsaved
? t("recorder.composer.saved")
: ""}
</span>
</span>
<Button variant="ghost" onClick={closeComposer}>
{t("recorder.composer.close")}
</Button>
<Button
variant="outline"
loading={save.isPending || exportZip.isPending}
disabled={send.isPending}
onClick={() => exportZip.mutate()}
>
{t("recorder.composer.export")}
</Button>
{target && !sent && (
// `sending`: a send already under way (a reload's, or another tab's). The button would only
// earn an `issue_sending` refusal; the banner above says why it is out.
<Button
variant="primary"
loading={send.isPending}
disabled={sending}
onClick={() => send.mutate()}
>
{t("recorder.composer.send")}
</Button>
)}
</ModalFooter>
</div>
);
}
function DraftFields({
form,
edit,
env,
readOnly,
}: {
form: DraftForm;
edit: (next: (f: DraftForm) => DraftForm) => void;
env: [string, string][];
readOnly: boolean;
}) {
const { t } = useTranslation();
// A removed step's ✕ goes with it: the focus moves to "add a step", which stays.
const addStep = useRef<HTMLButtonElement>(null);
const setStep = (i: number, patch: Partial<DraftStep>) =>
edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
return (
<div className="flex flex-col gap-4 min-w-0">
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-title" className="text-sm text-muted">
{t("recorder.composer.fieldTitle")}
</label>
<Input
className="w-full"
id="issue-title"
data-initial-focus
value={form.title}
readOnly={readOnly}
maxLength={200}
placeholder={t("recorder.composer.titlePlaceholder")}
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-description" className="text-sm text-muted">
{t("recorder.composer.description")}
</label>
<Textarea
className="w-full resize-y"
id="issue-description"
value={form.description}
readOnly={readOnly}
rows={4}
placeholder={t("recorder.composer.descriptionPlaceholder")}
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
/>
</div>
<fieldset className="flex flex-col gap-1.5 min-w-0">
<legend className="mb-1.5 text-sm text-muted">{t("recorder.composer.steps")}</legend>
<ol className="flex flex-col gap-1.5">
{form.steps.map((step, i) => (
<li
key={step.id}
className="flex items-center gap-2 rounded-lg bg-surface/60 pl-2.5 pr-1 py-1"
>
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
{i + 1}.
</span>
<Input
className="flex-1"
size="sm"
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
value={step.text}
readOnly={readOnly}
placeholder={t("recorder.composer.stepPlaceholder")}
onChange={(e) => setStep(i, { text: e.target.value })}
/>
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
{t(`recorder.composer.stepKind.${step.kind}`)}
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
</span>
{!readOnly && (
<IconButton
label={t("recorder.composer.removeStep", { n: i + 1 })}
size="sm"
variant="ghost"
onClick={() => {
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
addStep.current?.focus();
}}
>
<X className="w-4 h-4" />
</IconButton>
)}
</li>
))}
</ol>
{!readOnly && (
<Button
ref={addStep}
variant="link"
className="self-start"
icon={<Plus className="w-4 h-4" />}
onClick={() =>
edit((f) => ({
...f,
steps: [...f.steps, { id: `m${Date.now()}`, kind: "manual", text: "" }],
}))
}
>
{t("recorder.composer.addStep")}
</Button>
)}
</fieldset>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-muted">{t("recorder.composer.environment")}</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm">
{env.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted">{t(`recorder.composer.env.${k}`)}</dt>
<dd className="break-words tabular-nums">{v}</dd>
</div>
))}
</dl>
</div>
</div>
);
}
// The types the server keeps (backend debugsession.IMAGE_TYPES) — offered by the picker, and the
// only ones a paste or a drop hands over.
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
const isImage = (file: File) => IMAGE_TYPES.includes(file.type);
/** What changes with a screenshot: its list, and the session's count — not the session's events,
* which share the session's key prefix and are thousands of rows. */
function refreshShots(qc: ReturnType<typeof useQueryClient>, id: string) {
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(id) });
void qc.invalidateQueries({ queryKey: qk.debugSession(id), exact: true });
}
function Attachments({
session,
shotsQ,
counts,
readOnly,
adding,
onAdd,
onReview,
reviewed,
setReviewed,
}: {
session: DebugSession;
shotsQ: { data?: { items: DebugScreenshot[] }; isError: boolean; refetch: () => unknown };
counts: Record<Source, [kept: number, total: number]>;
readOnly: boolean;
adding: boolean;
onAdd: (files: Iterable<File>) => void;
onReview: (source: Source) => void;
reviewed: Source | null;
setReviewed: (spent: null) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const shots = shotsQ.data?.items ?? [];
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef<HTMLInputElement>(null);
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
// On mount after a review closed (this list unmounts while one is open), that log's Review button
// takes the focus back — once: the spent token keeps a later remount (back from the preview) off it.
useEffect(() => {
if (!reviewed) return;
reviewButtons.current[reviewed]?.focus();
setReviewed(null);
}, [reviewed, setReviewed]);
const remove = useMutation({
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
meta: { failTitle: "recorder.composer.removeShotFailed" },
onSuccess: () => refreshShots(qc, session.id),
});
const onDrop = (e: DragEvent) => {
e.preventDefault();
onAdd(e.dataTransfer.files);
};
const logRow = (source: Source, icon: React.ReactNode) => {
const [kept, total] = counts[source];
return (
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5">
{icon}
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">
{t(source === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog")}
</span>
<span className="text-xs text-muted tabular-nums">
{kept === total
? t("recorder.composer.lines", { count: total })
: t("recorder.composer.linesKept", { kept, total })}
{` · ${source === "web" ? "client.log" : "server.log"}`}
</span>
</div>
{total > 0 && (
<Button
ref={(el) => {
reviewButtons.current[source] = el;
}}
variant="outline"
size="sm"
onClick={() => onReview(source)}
>
{t("recorder.composer.review")}
</Button>
)}
</div>
);
};
return (
<div className={`${SURFACE.card} flex flex-col gap-3 min-w-0 p-3 md:p-4`}>
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{shotsQ.isError && !shotsQ.data && (
<StateMessage tone="error" compact onRetry={() => void shotsQ.refetch()}>
{t("recorder.composer.shotsFailed")}
</StateMessage>
)}
{shots.map((shot, i) => (
<div
key={shot.id}
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
>
<RemoteImage
src={api.debugScreenshotUrl(session.id, shot.id)}
alt={t("recorder.composer.screenshot", { n: i + 1 })}
className="h-10 w-16 shrink-0 rounded-md object-cover bg-surface"
fallback={<div className="h-10 w-16 shrink-0 rounded-md bg-surface" />}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size, lang)}</span>
</div>
{!readOnly && (
<IconButton
label={t("recorder.composer.removeShot", { n: i + 1 })}
size="sm"
variant="ghost"
loading={remove.isPending && remove.variables === shot.id}
onClick={() => {
remove.mutate(shot.id);
pick.current?.focus();
}}
>
<X className="w-4 h-4" />
</IconButton>
)}
</div>
))}
{!readOnly && (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
aria-busy={adding}
className="rounded-xl border border-dashed border-border px-3 py-4 text-center text-sm text-muted"
>
{t("recorder.composer.drop")}{" "}
<label className="cursor-pointer rounded font-semibold text-accent hover:underline focus-within:underline focus-within:ring-2 focus-within:ring-accent/60">
{t("recorder.composer.pick")}
<input
ref={pick}
type="file"
accept={IMAGE_TYPES.join(",")}
multiple
className="sr-only"
onChange={(e) => {
onAdd(e.target.files ?? []);
e.target.value = "";
}}
/>
</label>
</div>
)}
</div>
);
}
/** One log, line by line: an unticked line is not sent. */
function ReviewList({
rows,
name,
excluded,
readOnly,
onToggle,
onDone,
}: {
rows: ServerLogRow[];
name: string;
excluded: Set<number>;
readOnly: boolean;
onToggle: (id: number) => void;
onDone: () => void;
}) {
const { t } = useTranslation();
// Opened from its Review button, which is gone now: the focus comes here.
const done = useRef<HTMLButtonElement>(null);
useEffect(() => done.current?.focus(), []);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="font-semibold">{t("recorder.composer.reviewTitle", { name })}</div>
<div className="text-xs text-muted">{t("recorder.composer.reviewHint")}</div>
</div>
<Button ref={done} variant="primary" size="sm" onClick={onDone}>
{t("recorder.composer.reviewDone")}
</Button>
</div>
<ul className="max-h-[60dvh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
{rows.map((row) => {
const tag = LEVEL_TAG[row.level];
const off = excluded.has(row.id);
return (
<li key={row.id} className="border-b border-border/50 last:border-b-0">
<label
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 state-layer sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
off ? "opacity-50 line-through" : ""
}`}
>
<Checkbox
checked={!off}
disabled={readOnly}
aria-label={t("recorder.composer.keepRow", {
event: row.event ?? "",
msg: row.msg,
})}
onChange={() => onToggle(row.id)}
/>
<span className="tabular-nums text-muted">{logClock(row.ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
<span className="truncate">{row.event}</span>
<span className="col-span-4 truncate sm:col-span-1">{row.msg}</span>
</label>
</li>
);
})}
</ul>
</div>
);
}
/** The report as it will read. Through the tracker, its OWN rendering (Gitea's markdown and
* sanitiser) — drawn in a sandboxed frame: no script runs in it, and nothing it holds reaches the
* app's document. Without a tracker, the markdown itself: it is the zip's issue.md. */
function IssuePreview({
sessionId,
text,
viaTracker,
}: {
sessionId: string;
text: string;
viaTracker: boolean;
}) {
const { t } = useTranslation();
const q = useQuery({
queryKey: qk.debugSessionPreview(sessionId, text),
queryFn: () => api.previewDebugIssue(sessionId, text),
enabled: viaTracker,
});
const frame = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(240);
if (!viaTracker)
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted">{t("recorder.composer.previewLocal")}</p>
<pre className="max-h-[60dvh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
{text}
</pre>
</div>
);
if (q.isError && !q.data)
return (
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
{t("recorder.composer.previewFailed")}
</StateMessage>
);
if (!q.data) return <LoadingState />;
const css = getComputedStyle(document.documentElement);
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
// The frame is a document of its own: the app's text size (the root font, which the text-size
// setting drives) is handed to it, and everything in it is sized off that.
const doc = `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
html{font-size:${css.fontSize}}
body{margin:0;padding:.75rem .875rem;font:.875rem/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:.25rem .5rem}
code{font-family:ui-monospace,Consolas,monospace;font-size:.75rem}h1,h2,h3{line-height:1.25}
</style></head><body>${q.data.html}</body></html>`;
return (
<iframe
ref={frame}
title={t("recorder.composer.previewTitle")}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
srcDoc={doc}
onLoad={() => {
const body = frame.current?.contentDocument?.body;
if (body) setHeight(Math.min(body.scrollHeight + 4, window.innerHeight * 0.6));
}}
style={{ height }}
className="w-full rounded-xl border border-border"
/>
);
}
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import type { Level } from "../../lib/log";
import HistoryRow from "./HistoryRow";
import { LEVEL_TAG, logClock } from "./logLineParts";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
// in place to whatever detail its screen has. One component, so a level reads the same on both.
// The row frame is the history lists' (./HistoryRow): inside a NavGrid the keyboard walks the lines
// (`j`/`k`) and Enter opens one, as a click does.
export default function LogLine({
ts,
level,
event,
msg,
open,
onToggle,
title,
navIndex,
children,
}: {
ts: string;
level: Level;
event: string;
msg: string;
open: boolean;
onToggle: () => void;
/** The row button's tooltip — the admin console puts the full date there. */
title?: string;
/** The line's place in its NavGrid. */
navIndex: number;
/** The detail shown while the line is open. */
children: ReactNode;
}) {
const tag = LEVEL_TAG[level];
return (
<HistoryRow
line
grid="grid-cols-[6.5em_3.5em_minmax(0,1fr)] sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)]"
navIndex={navIndex}
navText={`${event} ${msg}`}
open={open}
onToggle={onToggle}
title={title}
cells={
<>
<span className="tabular-nums text-muted">{logClock(ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
<span className="truncate">{event}</span>
{/* A phone has no room for a fourth column: the message takes a line of its own under the
code, full width — sharing the code's line left it a few characters. */}
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
</>
}
>
{children}
</HistoryRow>
);
}
@@ -0,0 +1,21 @@
import type { Level } from "../../lib/log";
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
// LogLine.tsx because a component file exports only components (react-refresh).
export const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
export function logClock(ts: string): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
@@ -0,0 +1,124 @@
import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { captureTab } from "../../lib/captureTab";
import {
announceSessionChange,
closeRecorderStart,
nextStep,
openComposer,
setStepPrompt,
} from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe";
/** The debug recording's actions, shared by everything that drives it: the pill, Settings →
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
* answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const session = liveSession(me?.debug_session);
const setSession = (next: DebugSession | null) => {
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
announceSessionChange();
setStepPrompt(null); // a prompt left open belongs to the session that ended
};
const start = useMutation({
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
meta: { failTitle: "recorder.start.failed" },
onSuccess: (r) => {
setSession(r.session);
closeRecorderStart();
},
});
const stop = useMutation({
mutationKey: STOP_KEY,
mutationFn: async (id: string) => {
log.info("debug.stop", "recording stopped by the user");
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
// while the session still tags them: once it is closed the server no longer does.
await flushNow();
return api.stopDebugSession(id);
},
meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => {
setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") });
openComposer(r.session.id);
},
});
const shoot = useMutation({
mutationKey: SHOOT_KEY,
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
api.addDebugScreenshot(id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: (r) => {
updateMeCache(qc, (prev) =>
prev.debug_session
? {
...prev,
debug_session: {
...prev.debug_session,
screenshots: prev.debug_session.screenshots + 1,
},
}
: prev,
);
// In the evidence too, where the step list is drafted from: which step it belongs to.
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
notify({ level: "success", message: t("recorder.pill.shotSaved") });
},
});
// In flight anywhere, not only through this hook's own observer: the pill remounts when it moves
// to another toast host, and a Stop still running must keep its button busy there.
const stopping = useIsMutating({ mutationKey: STOP_KEY }) > 0;
const shooting = useIsMutating({ mutationKey: SHOOT_KEY }) > 0;
/** Mark the next step; `label` is what the user says they just did (may be empty). */
const sessionId = session?.id;
const markStep = useCallback(
(label: string) => {
if (!sessionId) return;
const n = nextStep(sessionId);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
},
[sessionId, t],
);
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
* frame. Nothing happens when the user cancels the browser's picker. */
const screenshot = async (hide: (hidden: boolean) => void) => {
if (!session) return;
hide(true);
let image: Blob | null = null;
try {
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab();
} catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed"));
}
hide(false);
if (image) shoot.mutate({ id: session.id, image });
};
return { session, start, stop, stopping, shoot, shooting, markStep, screenshot };
}
const STOP_KEY = ["debug-session", "stop"];
const SHOOT_KEY = ["debug-session", "screenshot"];
@@ -0,0 +1,70 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react";
import clsx from "clsx";
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api } from "../../lib/api";
import { IconButton } from "../../components/ui/IconButton";
// Self-contained download affordance for a video card / the player. Reflects the video's current state from
// the shared per-user download index (one polled query shared across every card).
export default function DownloadButton({
videoId,
title,
variant = "ghost",
size = "sm",
}: {
videoId: string;
title?: string | null;
variant?: "ghost" | "outline" | "overlay";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const indexQ = useQuery({
queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex,
staleTime: 10000,
});
const [open, setOpen] = useState(false);
const status = indexQ.data?.[videoId];
const downloaded = status === "done";
const inQueue = status === "queued" || status === "running" || status === "paused";
const label = downloaded
? t("downloads.button.downloaded")
: inQueue
? t("downloads.button.queued")
: t("downloads.button.label");
return (
<>
<IconButton
variant={variant}
size={size}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
label={label}
>
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
<Download
className={clsx(
(downloaded || inQueue) && "text-accent",
inQueue && "motion-safe:animate-pulse",
)}
/>
</IconButton>
{open && (
<Suspense fallback={null}>
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
</Suspense>
)}
</>
);
}
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
//
// The download dialog opens on the format used last (per account), so a pasted link is one Enter
// away; a format deleted since falls back to the first. Its "added" notice's View opens the tray on
// the new job. The prefs are a map that outlives a mount — a reload, as far as the dialog can tell.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type DownloadProfile } from "../../lib/api";
const h = vi.hoisted(() => ({
prefs: new Map<string, unknown>(),
notify: vi.fn(),
showInTray: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
);
return [
value,
(next: T) => {
h.prefs.set(key, next);
setValue(next);
},
];
},
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
import DownloadDialog from "./DownloadDialog";
const profile = (id: number, name: string) =>
({ id, name, is_builtin: false, spec: { mode: "av" } }) as unknown as DownloadProfile;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
h.prefs.clear();
h.notify.mockClear();
h.showInTray.mockClear();
});
function open() {
vi.spyOn(api, "downloadProfiles").mockResolvedValue([profile(1, "Best"), profile(2, "Phone")]);
const enqueue = vi.spyOn(api, "enqueueDownload").mockResolvedValue({ id: 77 } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
</QueryClientProvider>,
);
return enqueue;
}
const picked = () =>
screen.getByRole("button", { name: /^downloads\.dialog\.profile: / }).getAttribute("aria-label");
describe("DownloadDialog", () => {
it("opens on the format used last; a deleted one falls back to the first", async () => {
const enqueue = open();
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best"));
fireEvent.click(screen.getByRole("button", { name: /^downloads\.dialog\.profile: / }));
const phone = (await screen.findAllByRole("menuitemradio", { hidden: true })).find(
(o) => o.textContent === "Phone",
)!;
fireEvent.click(phone);
fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() =>
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ profile_id: 2 })),
);
cleanup();
open();
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Phone"));
cleanup();
h.prefs.set("downloadProfile", 99);
open();
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best"));
});
it("its notice's View opens the tray on the new job", async () => {
open();
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best"));
fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.showInTray).toHaveBeenCalledWith(77);
});
});
@@ -0,0 +1,127 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { api } from "../../lib/api";
import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab";
import { LS } from "../../lib/storage";
import { usePref } from "../../lib/usePref";
import FilterSelect from "../../components/ui/FilterSelect";
import { Input } from "../../components/ui/Input";
// Preset picker shown when the user hits Download on a card / in the player, or brings a link (the
// Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it.
// It opens on the format used last (per account): a pasted link is then one Enter away.
const parseProfile = (v: unknown): number | null => (typeof v === "number" ? v : null);
export default function DownloadDialog({
source,
title,
onClose,
}: {
source: string;
title?: string | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
const [profileId, setProfileId] = useState<number | null>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [manage, setManage] = useState(false);
const profiles = profilesQ.data ?? [];
const [lastProfile, setLastProfile] = usePref<number | null>("downloadProfile", null, {
localKey: LS.downloadProfile,
parse: parseProfile,
});
// The one used last, while it still exists (a deleted format falls back to the first).
const remembered = profiles.some((p) => p.id === lastProfile) ? lastProfile : null;
const chosen = profileId ?? remembered ?? profiles[0]?.id ?? null;
const submit = async () => {
if (!chosen || busy) return;
setBusy(true);
const displayName = name.trim() || undefined;
try {
const job = await api.enqueueDownload({
source,
profile_id: chosen,
display_name: displayName,
});
invalidateDownloads(qc);
setLastProfile(chosen);
notify({
level: "success",
message: t("downloads.dialog.added"),
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said.
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
setBusy(false);
}
};
return (
<Modal initialFocus="field" title={t("downloads.dialog.title")} onClose={onClose}>
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.dialog.profile")}
</span>
{/* The user's own list, of any length: a menu, not a segmented bar (owner's pick, 2026-09-19). */}
<FilterSelect
label={t("downloads.dialog.profile")}
hideLabel
selected={chosen == null ? [] : [String(chosen)]}
onChange={([v]) => v && setProfileId(Number(v))}
options={profiles.map((p) => ({ value: String(p.id), label: profileName(t, p) }))}
/>
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
{t("downloads.dialog.manage")}
</Button>
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.dialog.namePlaceholder")}
/>
<ModalFooter>
<Button variant="ghost" size="sm" onClick={onClose}>
{t("downloads.actions.cancel")}
</Button>
<Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
{t("downloads.dialog.submit")}
</Button>
</ModalFooter>
{manage && (
<Suspense fallback={null}>
<ProfileEditor
onClose={() => {
setManage(false);
void profilesQ.refetch();
}}
/>
</Suspense>
)}
</Modal>
);
}
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
//
// Downloading is an action you start anywhere: a lone http(s) link pasted onto a page (not into a
// field, not at a dialog) opens the app's one download dialog; the Files link box hands its link to
// the same dialog; the palette's "Download from link…" focuses that box on the entry it navigated to.
// The dialog itself is stubbed (it names the link it was opened on).
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("./DownloadDialog", () => ({
default: ({ source, onClose }: { source: string; onClose: () => void }) => (
<div role="dialog" aria-label="download">
{source}
<button onClick={onClose}>close</button>
</div>
),
}));
import DownloadIntake from "./DownloadIntake";
import { LinkBox } from "../../modules/files/LinkBox";
import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake";
import { ensureNavId } from "../../lib/history";
import { pushLayer, removeLayer } from "../../lib/layerStack";
afterEach(() => {
cleanup();
act(clearPendingDownload);
});
function paste(target: EventTarget, text: string) {
const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, "clipboardData", {
value: { getData: (type: string) => (type === "text/plain" ? text : "") },
});
act(() => {
target.dispatchEvent(event);
});
return event;
}
const dialog = () => screen.queryByRole("dialog", { name: "download" });
describe("DownloadIntake · a pasted link", () => {
it("a link pasted onto the page opens the download dialog on it", async () => {
render(<DownloadIntake />);
const event = paste(document.body, " https://www.tiktok.com/@a/video/1 ");
expect(await screen.findByText("https://www.tiktok.com/@a/video/1")).toBeTruthy();
// Taken: the browser does nothing else with it.
expect(event.defaultPrevented).toBe(true);
});
it("text that is not one link, or a paste into a field, is left alone", async () => {
render(
<>
<DownloadIntake />
<input aria-label="search" />
</>,
);
paste(document.body, "just some words");
paste(document.body, "https://a.example/1 https://a.example/2");
const field = screen.getByRole("textbox", { name: "search" });
expect(paste(field, "https://a.example/1").defaultPrevented).toBe(false);
await act(async () => {});
expect(dialog()).toBeNull();
});
it("with a dialog on top, a paste is the dialog's, not a download", async () => {
render(
<>
<DownloadIntake />
<div aria-modal="true" />
</>,
);
paste(document.body, "https://a.example/1");
await act(async () => {});
expect(dialog()).toBeNull();
});
it("with a player or a menu on top (a layer, no aria-modal), a paste is not a download", async () => {
render(<DownloadIntake />);
const layer = pushLayer();
try {
paste(document.body, "https://a.example/1");
await act(async () => {});
expect(dialog()).toBeNull();
} finally {
removeLayer(layer);
}
});
it("closing the dialog closes it", async () => {
render(<DownloadIntake />);
paste(document.body, "https://a.example/1");
fireEvent.click(await screen.findByText("close"));
expect(dialog()).toBeNull();
});
});
describe("LinkBox", () => {
it("Enter hands the typed link to the dialog and empties the box", async () => {
render(
<>
<DownloadIntake />
<LinkBox />
</>,
);
const field = screen.getByRole("textbox", { name: "files.link.label" });
fireEvent.change(field, { target: { value: " https://vimeo.com/123 " } });
fireEvent.keyDown(field, { key: "Enter" });
expect(await screen.findByText("https://vimeo.com/123")).toBeTruthy();
expect((field as HTMLInputElement).value).toBe("");
});
it("a clipboard link read on focus never replaces what was typed while it was read", async () => {
let answer: (text: string) => void = () => {};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { readText: () => new Promise<string>((resolve) => (answer = resolve)) },
});
render(<LinkBox />);
const box = screen.getByRole("textbox");
fireEvent.focus(box);
fireEvent.change(box, { target: { value: "https://typed.example/1" } });
await act(async () => answer("https://clipboard.example/2"));
expect((box as HTMLInputElement).value).toBe("https://typed.example/1");
});
it("an empty box downloads nothing", async () => {
render(
<>
<DownloadIntake />
<LinkBox />
</>,
);
fireEvent.click(screen.getByText("files.link.download"));
await act(async () => {});
expect(dialog()).toBeNull();
});
it("the palette's request focuses the box on its entry, once", () => {
ensureNavId();
act(requestLinkBox);
const { unmount } = render(<LinkBox />);
expect(document.activeElement).toBe(screen.getByRole("textbox", { name: "files.link.label" }));
unmount();
// Spent: the next mount on the same entry is not focused again.
render(<LinkBox />);
expect(document.activeElement).toBe(document.body);
});
it("a request made for another entry (the navigation never got here) focuses nothing", () => {
ensureNavId();
act(requestLinkBox);
// A later entry — say Back, then elsewhere — mounts the box.
window.history.pushState({}, "");
ensureNavId();
render(<LinkBox />);
expect(document.activeElement).toBe(document.body);
});
});
@@ -0,0 +1,42 @@
import { lazy, Suspense, useEffect } from "react";
import {
asDownloadLink,
clearPendingDownload,
pasteIsForThePage,
requestDownload,
usePendingDownload,
} from "../../lib/downloadIntake";
const DownloadDialog = lazy(() => import("./DownloadDialog"));
// The app's one download dialog (lib/downloadIntake), and the paste that opens it: a lone http(s)
// link pasted onto any page — not into a field — is downloaded, the profile picked in the dialog.
export default function DownloadIntake() {
const source = usePendingDownload();
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
if (e.defaultPrevented || !pasteIsForThePage(e.target)) return;
const link = asDownloadLink(e.clipboardData?.getData("text/plain"));
if (!link) return;
e.preventDefault();
requestDownload(link);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, []);
return (
<Suspense fallback={null}>
{/* The link itself under the title: a paste can be an accident, and this says what it was. */}
{source && (
<DownloadDialog
key={source}
source={source}
title={source}
onClose={clearPendingDownload}
/>
)}
</Suspense>
);
}
@@ -0,0 +1,389 @@
// @vitest-environment jsdom
//
// The download tray: up only while something waits, runs or failed — and what finished in this tab,
// until it is closed; a row's right click and "…" are the queue's menu; a failure says why and offers
// a retry; a finish is announced once with "Copy link" and "Open"; it folds to a pill, remembered.
// The queue is fed straight in (the poll is stubbed); a poll's look is `noteQueue`, as fetchQueue does.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type QueueJob } from "../../lib/api";
const h = vi.hoisted(() => ({
jobs: [] as unknown[],
updatedAt: 0,
openFile: vi.fn(),
share: vi.fn(async () => "https://x/watch/t"),
notify: vi.fn(),
prefs: new Map<string, unknown>(),
intervalMs: ((_d: unknown) => 0) as (d: unknown) => number,
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string | string[], o?: { count?: number; title?: string }) =>
`${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`,
}),
}));
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
(h.intervalMs = opts.intervalMs),
{
data: h.jobs,
dataUpdatedAt: h.updatedAt,
isError: false,
isLoading: false,
refetch: vi.fn(),
}
),
}));
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
// usePref stores them is its own business (usePref.dom.test).
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
);
return [
value,
(next: T) => {
h.prefs.set(key, next);
setValue(next);
},
];
},
}));
vi.mock("../../modules/files/filesContext", () => ({
useFilesActions: () => ({ openFile: h.openFile }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("../../lib/downloads", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/downloads")>()),
quickShareLink: h.share,
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
beforeEach(() => {
resetQueueWatch();
h.prefs.clear();
h.updatedAt = 0;
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
for (const f of [h.openFile, h.share, h.notify]) f.mockClear();
});
const job = (id: number, status: string, extra: Partial<QueueJob> = {}) =>
({
id,
status,
progress: 40,
phase: "video",
error_code: null,
error_detail: null,
job_kind: "download",
display_name: `Clip ${id}`,
source_key: "site:tiktok.com",
folder_id: 9,
asset: { media_kind: "video" },
...extra,
}) as unknown as QueueJob;
/** A poll: the queue the tray now sees. */
function poll(jobs: QueueJob[], view?: { rerender: () => void }) {
h.jobs = jobs;
act(() => noteQueue(jobs));
view?.rerender();
}
function mount() {
const qc = new QueryClient();
const tree = () => (
<QueryClientProvider client={qc}>
<DownloadTray />
</QueryClientProvider>
);
const { rerender } = render(tree());
return { rerender: () => act(() => rerender(tree())) };
}
const tray = () => screen.queryByRole("region", { name: "tray.label" });
const rowOf = (title: string) => screen.getByText(title).closest<HTMLElement>("[data-job-id]")!;
describe("DownloadTray", () => {
it("is not there while nothing waits, runs or failed", () => {
poll([job(1, "done")]);
mount();
expect(tray()).toBeNull();
});
it("lists what waits, runs and failed, its sites, and sums them up", () => {
poll([job(1, "running"), job(2, "queued"), job(3, "error", { error_code: "login_required" })]);
mount();
expect(tray()).not.toBeNull();
expect(screen.getByText(/tray\.count\.running:2, tray\.count\.error:1/)).toBeTruthy();
expect(within(rowOf("Clip 1")).getByText("tiktok.com")).toBeTruthy();
expect(within(rowOf("Clip 1")).getByText("40%")).toBeTruthy();
expect(within(rowOf("Clip 2")).getByText("downloads.status.queued")).toBeTruthy();
});
it("a running job names its step: the second stream's bar does not read as a restart", () => {
poll([job(1, "running", { phase: "audio" }), job(2, "running", { phase: "merging" })]);
mount();
const [audio, merging] = screen.getAllByRole("progressbar");
expect(within(rowOf("Clip 1")).getByText("downloads.phase.audio")).toBeTruthy();
expect(audio!.getAttribute("aria-valuenow")).toBe("40");
// A step that reports no bytes has no % to claim.
expect(within(rowOf("Clip 2")).getByText("downloads.phase.merging")).toBeTruthy();
expect(merging!.getAttribute("aria-valuenow")).toBeNull();
expect(within(rowOf("Clip 2")).queryByText("40%")).toBeNull();
});
it("a paused download is waited on slowly, and the tray neither spins nor says done", () => {
poll([job(1, "paused")]);
mount();
// Nothing moves until its user resumes it: no fast polling for that.
expect(h.intervalMs([job(1, "paused")])).toBe(60000);
expect(h.intervalMs([job(1, "paused"), job(2, "queued")])).toBe(2000);
const marks = [...tray()!.querySelectorAll("svg")].map((i) => i.getAttribute("class") ?? "");
expect(marks.some((c) => /lucide-(circle-check|check-circle)/.test(c))).toBe(false);
expect(
marks.some((c) => /lucide-(circle-pause|pause-circle)/.test(c) && /text-warn/.test(c)),
).toBe(true);
});
it("a failure says why, and its Retry resumes it", () => {
const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never);
poll([job(3, "error", { error_code: "login_required" })]);
mount();
expect(
within(rowOf("Clip 3")).getByText("downloads.errors.fail.cat.login_required.short"),
).toBeTruthy();
fireEvent.click(within(rowOf("Clip 3")).getByText("downloads.actions.retry"));
return waitFor(() => expect(resume).toHaveBeenCalledWith(3));
});
it("a row's right click opens the queue's menu", async () => {
poll([job(1, "running")]);
mount();
act(() => {
fireEvent.contextMenu(rowOf("Clip 1"));
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toContain(
"downloads.actions.pause",
),
);
});
it("a finish is announced once — Copy link, Open — and stays as Done · Open", async () => {
poll([job(1, "running")]);
const view = mount();
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as {
title: string;
message: string;
action: { label: string; onClick: () => void };
secondaryAction: { label: string; onClick: () => void };
};
expect(notice.title).toBe("tray.done.title:Clip 1");
expect(notice.message).toBe("tray.done.phone");
expect(notice.action.label).toBe("tray.done.copyLink");
notice.action.onClick();
expect(h.share).toHaveBeenCalled();
notice.secondaryAction.onClick();
expect(h.openFile).toHaveBeenCalledWith(1, 9);
// Still on the tray, done; its Open opens it.
fireEvent.click(within(rowOf("Clip 1")).getByText("tray.done.open"));
expect(h.openFile).toHaveBeenCalledTimes(2);
view.rerender();
expect(h.notify).toHaveBeenCalledTimes(1);
});
it("each finish is announced once: a later one does not repeat an earlier", async () => {
poll([job(1, "running"), job(2, "running")]);
const view = mount();
poll([job(1, "done"), job(2, "running")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
"tray.done.title:Clip 1",
"tray.done.title:Clip 2",
]);
});
it("a plain file's notice does not promise a phone", async () => {
poll([job(1, "running", { asset: { media_kind: "file" } as never })]);
const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })),
);
});
it("a finish refreshes Files at once, so the notice's Open finds the file listed", async () => {
const invalidate = vi.spyOn(QueryClient.prototype, "invalidateQueries");
poll([job(1, "running")]);
const view = mount();
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: qk.files() });
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.fileFolders() });
});
it("an upload the worker converted first is announced as uploaded, not downloaded", async () => {
poll([job(1, "running", { source_key: "upload" })]);
const view = mount();
poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
),
);
});
it("closed, it forgets what finished and stays away until the work changes", () => {
poll([job(1, "running"), job(2, "running")]);
const view = mount();
poll([job(1, "running"), job(2, "done")], view);
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(tray()).toBeNull();
// The same work moving on is not news…
poll([job(1, "running", { progress: 80 })], view);
expect(tray()).toBeNull();
// …a new job is.
poll([job(1, "running"), job(4, "queued")], view);
expect(tray()).not.toBeNull();
expect(screen.queryByText("Clip 2")).toBeNull();
});
it("closed on an old failure, it stays closed after a reload", () => {
poll([job(3, "error")]);
const first = render(
<QueryClientProvider client={new QueryClient()}>
<DownloadTray />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
first.unmount();
resetQueueWatch();
poll([job(3, "error")]);
mount();
expect(tray()).toBeNull();
});
it("closed on an old failure, a new download's finish still shows", () => {
poll([job(3, "error")]);
const view = mount();
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(tray()).toBeNull();
poll([job(4, "running"), job(3, "error")], view);
expect(tray()).not.toBeNull();
// Done, the queue is the old failure alone again — the very queue it was closed on.
poll([job(4, "done"), job(3, "error")], view);
expect(tray()).not.toBeNull();
expect(within(rowOf("Clip 4")).getByText("tray.done.label")).toBeTruthy();
});
it("folds to a pill that sums it up, and opens again — the keyboard following", () => {
poll([job(1, "running")]);
mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
expect(screen.queryByText("Clip 1")).toBeNull();
const pill = screen.getByRole("button", { name: /tray\.count\.running:1/ });
expect(document.activeElement).toBe(pill);
fireEvent.click(pill);
expect(screen.getByText("Clip 1")).toBeTruthy();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "tray.fold" }));
});
it("closed, it leaves the keyboard on the page", () => {
poll([job(1, "running")]);
render(
<QueryClientProvider client={new QueryClient()}>
<main id="content" tabIndex={-1} />
<DownloadTray />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(document.activeElement?.id).toBe("content");
});
describe("View on a job just started", () => {
it("brings a closed tray back, on the job's row", async () => {
poll([job(3, "error")]);
const view = mount();
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(tray()).toBeNull();
act(() => showInTray(3));
view.rerender();
expect(tray()).not.toBeNull();
await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 3")));
});
it("unfolds a folded tray onto the row", async () => {
poll([job(1, "running"), job(2, "queued")]);
mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
act(() => showInTray(2));
await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 2")));
});
it("asked from inside a player, it opens under it but leaves the keyboard there", async () => {
poll([job(1, "running"), job(2, "queued")]);
mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
const player = document.createElement("button");
document.body.append(player);
player.focus();
const layer = pushLayer();
act(() => showInTray(2));
await waitFor(() => expect(screen.getByText("Clip 2")).toBeTruthy());
expect(document.activeElement).toBe(player);
removeLayer(layer);
player.remove();
});
it("a job done before the tray saw it run opens as a file", () => {
// The queue's first look only learns what is there: this one never ran in this tab.
poll([job(5, "done", { folder_id: 7 })]);
mount();
act(() => showInTray(5));
expect(h.openFile).toHaveBeenCalledWith(5, 7);
expect(tray()).toBeNull();
});
it("a job a later poll does not have is let go — nothing opens, nothing waits for it", () => {
poll([job(1, "running")]);
const view = mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
act(() => showInTray(9));
h.updatedAt = Date.now() + 1000;
poll([job(1, "running")], view);
// Had the ask stayed, the job turning up later would pull the tray open on it.
poll([job(1, "running"), job(9, "running")], view);
expect(screen.queryByText("Clip 9")).toBeNull();
expect(h.openFile).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,271 @@
// @vitest-environment jsdom
//
// The tray's row menu: the copies and cancel of a waiting, running or failed job are its ONE menu,
// opened by the row's "…" (it shows on hover or focus) or a right-click on the row; a failed job's
// first row is Retry. The cancel confirm names its ACTION on its button, and speaks of what is
// cancelled (a download, an upload, a clip). The tray's own showing and done rows are
// DownloadTray.dom.test.tsx's; a finished file's menu is the Files page's (Files.menu.dom.test.tsx).
// i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type DownloadJob } from "../../lib/api";
import { menuGroups } from "../../components/ui/menuGroups";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
confirm: vi.fn(
async (_o?: { title: string; message?: string; confirmLabel?: string; danger?: boolean }) =>
true,
),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(_key: string, initial: T) => useState<T>(initial),
}));
vi.mock("../../modules/files/filesContext", () => ({
useFilesActions: () => ({ openFile: vi.fn() }),
}));
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
import DownloadTray from "./DownloadTray";
const job = (
id: number,
status: DownloadJob["status"],
extra: Partial<DownloadJob> = {},
): DownloadJob =>
({
id,
status,
progress: 0,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
error_detail: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: `v${id}`,
source_url: null,
poster_url: null,
profile_id: null,
spec: { mode: "av" },
display_name: `Clip ${id}`,
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
job_kind: "download",
source_key: "youtube",
asset: { media_kind: "video", duration_s: 60 },
...extra,
}) as unknown as DownloadJob;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
function mount() {
const qc = new QueryClient();
const tree = () => (
<QueryClientProvider client={qc}>
<DownloadTray />
</QueryClientProvider>
);
const { rerender } = render(tree());
mounted.rerender = () => rerender(tree());
}
// The last mount's re-render: what a poll's new data does to the tray.
const mounted = { rerender: () => {} };
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="tray.rowMenu"]')!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
describe("DownloadTray · the row menu", () => {
it("the '…' hides until hover or focus", () => {
mocks.jobs = [job(1, "running")];
mount();
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
expect(rowOf("Clip 1").classList.contains("group")).toBe(true);
});
it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => {
mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
expect(menuRows()).toEqual([
"downloads.actions.pause",
"common.copy.title",
"common.copy.link",
"downloads.actions.cancel",
]);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true");
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.retry",
"common.copy.title",
"downloads.actions.cancel",
]);
});
it("draws Retry as 'do it again', never the Play glyph", async () => {
mocks.jobs = [job(1, "error")];
mount();
const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? "";
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
const row = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.retry")!;
expect(glyph(row)).toContain("lucide-refresh-cw");
});
it("closes on a second press of the same '…'", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false");
});
it("still closes when a poll replaced the job under the open menu", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The queue polls while a job runs: the next render lists a NEW object for the same job.
mocks.jobs = [job(1, "running", { progress: 40 })];
mounted.rerender();
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
});
it("cancels only through the confirm", async () => {
mocks.jobs = [job(1, "queued")];
const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never);
mount();
const pickCancel = async () => {
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.cancel")!,
);
};
mocks.confirm.mockResolvedValueOnce(false);
await pickCancel();
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(cancel).not.toHaveBeenCalled();
await pickCancel();
await waitFor(() => expect(cancel).toHaveBeenCalledWith(1));
});
});
describe("DownloadTray · the cancel confirm", () => {
const cancelOf = async (title: string) => {
mount();
await openMenu(() => fireEvent.click(moreOf(title)));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.cancel")!,
);
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
return mocks.confirm.mock.calls[0]![0]!;
};
it("labels its button with the action, not the dialog's question", async () => {
mocks.confirm.mockResolvedValueOnce(false);
mocks.jobs = [job(1, "running")];
const o = await cancelOf("Clip 1");
expect([o.title, o.confirmLabel]).toEqual([
"downloads.confirm.cancelTitle",
"downloads.confirm.cancelAction",
]);
});
it("cancelling an upload says the uploaded file goes with it", async () => {
// Nothing to fetch again: the cancel deletes the original from the server.
mocks.confirm.mockResolvedValueOnce(false);
mocks.jobs = [job(3, "queued", { job_kind: "upload" })];
const o = await cancelOf("Clip 3");
expect([o.title, o.confirmLabel]).toEqual([
"downloads.confirm.cancelUploadTitle",
"downloads.confirm.cancelUploadAction",
]);
});
it("cancelling a clip speaks of the clip", async () => {
mocks.confirm.mockResolvedValueOnce(false);
mocks.jobs = [job(4, "running", { job_kind: "edit" })];
const o = await cancelOf("Clip 4");
expect([o.title, o.message, o.confirmLabel]).toEqual([
"downloads.confirm.cancelEditTitle",
"downloads.confirm.cancelEditBody",
"downloads.confirm.cancelEditAction",
]);
});
});
describe("menuGroups", () => {
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
it("rules between non-empty groups only", () => {
const out = menuGroups([], [row("a"), row("b")], [], [row("c")]);
expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([
["a", false],
["b", false],
["c", true],
]);
});
});
@@ -0,0 +1,468 @@
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
FolderOpen,
Link,
Loader2,
PauseCircle,
RotateCw,
X,
} from "lucide-react";
import type { QueueJob } from "../../lib/api";
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
import {
IN_FLIGHT,
DOWNLOAD_PHASES,
MOVING,
clearFinished,
doneRevealing,
fetchQueue,
takeUnannounced,
useFinished,
useRevealing,
useUnannounced,
} from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref";
import { ErrorModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { sourceLabel } from "../../modules/files/fileFolders";
import { useFilesActions } from "../../modules/files/filesContext";
import { useDownloadActions } from "./useDownloadActions";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { setCornerLift } from "../../components/ui/backToTopState";
import { Button } from "../../components/ui/Button";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { IconButton } from "../../components/ui/IconButton";
import { menuGroups } from "../../components/ui/menuGroups";
import { MoreButton } from "../../components/ui/MoreButton";
import NavGrid from "../../components/ui/NavGrid";
import { Pressable } from "../../components/ui/Pressable";
import { CONTENT_ID } from "../../components/ui/landmarks";
import { SURFACE } from "../../components/ui/surfaces";
import { useContextMenu } from "../../components/ui/useContextMenu";
// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner,
// up only while something waits, runs or failed — and what finished in this tab, "Done · Open",
// until it is closed. Folds to a pill (remembered per account). A row's right click and "…" are the
// queue's one menu; a finish is announced once with "Copy link" and "Open" (DESIGN.md "Download
// done"). It sits under every dialog and player (z-chrome), so it never covers a player's controls.
type Fold = "open" | "folded";
const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open");
const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null);
// Byte progress (a bar with a %) vs a post-step that reports none (a pulse): "editing" and
// "converting" report a real % from ffmpeg's out_time.
const BYTE_PHASES = ["video", "audio", "editing", "converting"];
const keyOf = (job: QueueJob) => job.id;
// The step a running job is on is named beside its bar: a download fetches its video and its audio
// as two streams, so the bar fills twice — without the name, the second start reads as a restart.
function Progress({ job }: { job: QueueJob }) {
const { t } = useTranslation();
const bytes = !job.phase || BYTE_PHASES.includes(job.phase);
const step =
job.phase && DOWNLOAD_PHASES.includes(job.phase) ? t(`downloads.phase.${job.phase}`) : null;
return (
<span className="flex min-w-0 items-center gap-2 text-xs text-muted">
{/* One column for the step, so every row's bar starts on the same line. */}
{step && (
<span className="w-36 shrink-0 truncate" title={step}>
{step}
</span>
)}
<span
role="progressbar"
aria-label={step ?? t("downloads.status.running")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={bytes ? job.progress : undefined}
className="h-1 min-w-12 flex-1 overflow-hidden rounded-full bg-fg/10"
>
{bytes ? (
<span
className="block h-full rounded-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
) : (
<span className="block h-full w-full motion-safe:animate-pulse bg-accent/60" />
)}
</span>
{bytes && <span className="w-8 shrink-0 text-right tabular-nums">{job.progress}%</span>}
</span>
);
}
export default function DownloadTray() {
const { t } = useTranslation();
const qc = useQueryClient();
const { openFile } = useFilesActions();
const actions = useDownloadActions();
// Every page polls it now, so the idle cadence is slow: a job started here refetches at once
// (invalidateDownloads), and one started on another device shows within a minute.
const queueQ = useLiveQuery(qk.downloads(), fetchQueue, {
intervalMs: (data) => (data?.some((j) => MOVING.has(j.status)) ? 2000 : 60000),
});
const finished = useFinished();
const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error");
const activeIds = new Set(active.map((j) => j.id));
const rows = [...active, ...finished.filter((j) => !activeIds.has(j.id))];
// Closed with work still on it, it stays away until that work changes: a new job, or one failing.
// Remembered (per account) past a reload: an old failure the reader has seen must not reopen it
// on every page they load.
const signature = active.map((j) => `${j.id}${j.status === "error" ? "!" : ""}`).join(",");
const [closedAt, setClosedAt] = usePref<string | null>("downloadTrayClosed", null, {
localKey: LS.downloadTrayClosed,
parse: parseClosed,
});
// A finish since it was closed is news of its own, whatever else is still on it.
const shown = rows.length > 0 && (signature !== closedAt || finished.length > 0);
const [fold, setFold] = usePref<Fold>("downloadTray", "open", {
localKey: LS.downloadTray,
parse: parseFold,
});
const [errorJob, setErrorJob] = useState<QueueJob | null>(null);
const menu = useContextMenu<QueueJob>({ keyOf });
// Folding swaps the control under the keyboard for its counterpart: the focus follows it there
// (the pill, or the fold button), never left on <body> where the pressed one was.
const pill = useRef<HTMLButtonElement>(null);
const foldButton = useRef<HTMLButtonElement>(null);
const focusNext = useRef<Fold | null>(null);
const toggleFold = (next: Fold) => {
focusNext.current = next;
setFold(next);
};
useEffect(() => {
const target = focusNext.current;
focusNext.current = null;
if (target === "folded") pill.current?.focus();
else if (target === "open") foldButton.current?.focus();
}, [fold]);
// --- "View" on a job just started: the tray opens on its row ----------------------------------
// Answered over as many renders as it takes: a closed or folded tray is brought back first, and the
// row is focused once it is on the page. A job the tray does not list is done already (it opens as
// a file), or — once a poll made after the ask has landed without it — not in the queue at all.
// Asked from inside a player (a clip's notice, the player's own download), the tray opens under it
// but leaves the keyboard there: the row is behind the player, out of the reader's sight.
const box = useRef<HTMLDivElement>(null);
const revealing = useRevealing();
const answerReveal = useEffectEvent(() => {
if (!revealing) return;
const index = rows.findIndex((j) => j.id === revealing.id);
if (index >= 0) {
if (!shown) setClosedAt(null);
else if (fold === "folded") setFold("open");
else {
if (box.current && !anyBlockingLayer()) focusNavItem(box.current, index);
doneRevealing();
}
return;
}
const job = queueQ.data?.find((j) => j.id === revealing.id);
if (job?.status === "done") {
doneRevealing();
openFile(job.id, job.folder_id);
} else if (queueQ.dataUpdatedAt > revealing.at) doneRevealing();
else void qc.invalidateQueries({ queryKey: qk.downloads() });
});
useEffect(() => answerReveal(), [revealing, queueQ.data, shown, fold]);
// --- the done notice ------------------------------------------------------------------------
const announce = useEffectEvent((job: QueueJob) => {
const title = jobTitle(job);
notify({
level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded.
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title,
}),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: {
label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
secondaryAction: {
label: t("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id),
},
});
});
const fresh = useUnannounced();
useEffect(() => {
// The take clears them, so a second pass (StrictMode, a re-render) announces nothing again.
if (!fresh.length) return;
// A notice that offers "Open" while Files still lists the folder without the file (its own poll
// is slower) sends the reader to something not there yet.
invalidateDownloads(qc);
takeUnannounced().forEach(announce);
}, [fresh, qc]);
// --- Back to top steps up over the tray while it shows -----------------------------------------
useLayoutEffect(() => {
const el = box.current;
if (!el) {
setCornerLift(0);
return;
}
const measure = () => setCornerLift(el.getBoundingClientRect().height);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
ro.disconnect();
setCornerLift(0);
};
}, [shown, fold]);
if (!shown)
return errorJob ? (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
) : null;
const counts = {
running: rows.filter((j) => j.status === "queued" || j.status === "running").length,
paused: rows.filter((j) => j.status === "paused").length,
error: rows.filter((j) => j.status === "error").length,
done: rows.filter((j) => j.status === "done").length,
};
const summary = (["running", "paused", "error", "done"] as const)
.filter((k) => counts[k] > 0)
.map((k) => t(`tray.count.${k}`, { count: counts[k] }))
.join(", ");
const running = rows.filter((j) => j.status === "running");
const meanProgress = running.length
? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length)
: null;
// The tray's state, worst news first; a paused job is neither working nor done.
const StateIcon = counts.running
? Loader2
: counts.error
? AlertTriangle
: counts.paused
? PauseCircle
: CheckCircle2;
const stateIcon = (
<StateIcon
aria-hidden
className={clsx(
"size-4 shrink-0",
counts.running
? "animate-spin text-accent"
: counts.error
? "text-danger"
: counts.paused
? "text-warn"
: "text-success",
)}
/>
);
const close = () => {
clearFinished();
setClosedAt(signature);
// The tray goes with the button pressed: the keyboard rests on the page (its <main>).
document.getElementById(CONTENT_ID)?.focus({ preventScroll: true });
};
const doneItems = (job: QueueJob): ActionMenuItem[] =>
menuGroups([
{
id: "open",
label: t("tray.done.open"),
icon: <FolderOpen className="w-4 h-4" />,
onSelect: () => openFile(job.id, job.folder_id),
},
{
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
]);
const menuItems = menu.target
? menu.target.status === "done"
? doneItems(menu.target)
: actions.items(menu.target)
: [];
return (
<div
ref={box}
role="region"
aria-label={t("tray.label")}
className="absolute right-4 bottom-4 z-chrome flex max-w-[calc(100%-2rem)] flex-col items-end"
>
{fold === "folded" ? (
<Pressable
ref={pill}
onClick={() => toggleFold("open")}
aria-expanded={false}
title={t("tray.unfold")}
className={clsx(
SURFACE.floating,
"inline-flex items-center gap-2 px-3 py-1.5 text-sm glass-hover",
)}
>
{stateIcon}
<span className="truncate">{summary}</span>
{meanProgress !== null && (
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-fg/10">
<span
className="block h-full rounded-full bg-accent"
style={{ width: `${meanProgress}%` }}
/>
</span>
)}
</Pressable>
) : (
<div
className={clsx(
SURFACE.popover,
"@container/tray w-80 max-w-full overflow-hidden shadow-float",
)}
>
<div className="flex items-center gap-2 border-b border-border/60 py-1 pr-1 pl-3">
{stateIcon}
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{/* A phone's narrow tray keeps the news and drops the name (the region names it). */}
<span className="@max-[18rem]/tray:hidden">{t("tray.title")} · </span>
{summary}
</span>
<IconButton
ref={foldButton}
label={t("tray.fold")}
size="sm"
variant="ghost"
aria-expanded
onClick={() => toggleFold("folded")}
>
<ChevronDown />
</IconButton>
<IconButton label={t("tray.close")} size="sm" variant="ghost" onClick={close}>
<X />
</IconButton>
</div>
<NavGrid className="max-h-[50dvh] overflow-y-auto py-1">
{rows.map((job, i) => {
const title = jobTitle(job);
const mine = menu.open && menu.target?.id === job.id;
return (
<div
key={job.id}
{...navItemAttrs(i, title)}
{...menu.triggerProps(job)}
data-job-id={job.id}
className="group flex items-start gap-1 px-3 py-2 focus-visible:bg-fg/5"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<span className="shrink-0 text-xs text-muted">
{sourceLabel(job.source_key, t)}
</span>
</span>
<RowState
job={job}
onShowError={() => setErrorJob(job)}
onRetry={() => actions.run(job, "resume")}
onOpen={() => openFile(job.id, job.folder_id)}
/>
</div>
<MoreButton
label={t("tray.rowMenu")}
size="sm"
expanded={mine}
menuProps={menu.buttonProps(job)}
className={mine ? undefined : "reveal"}
/>
</div>
);
})}
</NavGrid>
</div>
)}
<ContextMenu menu={menu} ariaLabel={t("tray.rowMenu")} items={menuItems} />
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}
</div>
);
}
/** What a tray row says under its title: how far it is, that it waits, why it failed, or that it is
* done — with the one thing to do about it in reach. */
function RowState({
job,
onShowError,
onRetry,
onOpen,
}: {
job: QueueJob;
onShowError: () => void;
onRetry: () => void;
onOpen: () => void;
}) {
const { t } = useTranslation();
if (job.status === "running") return <Progress job={job} />;
if (job.status === "error")
return (
<span className="flex items-center gap-2 text-xs">
<AlertTriangle aria-hidden className="size-3.5 shrink-0 text-danger" />
<Pressable
onClick={onShowError}
title={t(failTitleKey(job))}
className="min-w-0 flex-1 truncate text-left text-danger hover:underline"
>
{t(failCopyKeys(job, "short"))}
</Pressable>
<Button
variant="link"
size="none"
icon={<RotateCw className="size-3" />}
onClick={onRetry}
className="text-xs"
>
{t("downloads.actions.retry")}
</Button>
</span>
);
if (job.status === "done")
return (
<span className="flex items-center gap-2 text-xs">
<CheckCircle2 aria-hidden className="size-3.5 shrink-0 text-success" />
<span className="flex-1 text-success">{t("tray.done.label")}</span>
<Button variant="link" size="none" onClick={onOpen} data-nav-open="" className="text-xs">
{t("tray.done.open")}
</Button>
</span>
);
return <span className="text-xs text-muted">{t(`downloads.status.${job.status}`)}</span>;
}
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// The failure window offers a Retry only where a retry can help: a transient failure, or a site the
// downloader did not understand (an update on the server cures it). Never for an unclassified one.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { DownloadJob } from "../../lib/api";
import { ErrorModal } from "./downloadModals";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
}),
}));
afterEach(cleanup);
function open(error_code: string) {
const onRetry = vi.fn();
const job = { id: 1, status: "error", error: "ERROR: x", error_code } as unknown as DownloadJob;
render(<ErrorModal job={job} onRetry={onRetry} onClose={() => {}} />);
return onRetry;
}
describe("ErrorModal", () => {
it("offers a retry when the site changed under the downloader", () => {
const onRetry = open("site_changed");
expect(screen.getByText("downloads.errors.fail.cat.site_changed.short")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "downloads.actions.retry" }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("offers none for an unclassified failure", () => {
open("unknown");
expect(screen.queryByRole("button", { name: "downloads.actions.retry" })).toBeNull();
});
});
@@ -0,0 +1,170 @@
import { useState } from "react";
import { invalidateDownloads } from "../../lib/downloads";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { api, type DownloadJob } from "../../lib/api";
import { Button } from "../../components/ui/Button";
import { IconBtn } from "./downloadRow";
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
import { Input } from "../../components/ui/Input";
// Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed
// download's reasons (the tray).
// Edit a library file's details: its channel (name + link) and extra reference links. Display-only
// overrides — the on-disk file is never touched. Its name is renamed in place in the Files list.
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(
job.display_uploader_url || job.asset?.uploader_url || "",
);
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
const addLink = () => setLinks((ls) => [...ls, ""]);
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
const save = useMutation({
mutationFn: () =>
api.updateFile(job.id, {
uploader: channel.trim(),
uploader_url: channelUrl.trim(),
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => {
invalidateDownloads(qc);
onClose();
},
meta: { failTitle: "errors.writeFailed.downloadDetails" },
});
return (
<Modal initialFocus="field" title={t("downloads.edit.title")} onClose={onClose}>
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
<Input className="w-full" value={channel} onChange={(e) => setChannel(e.target.value)} />
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
<Input
className="w-full"
value={channelUrl}
onChange={(e) => setChannelUrl(e.target.value)}
placeholder="https://…"
/>
</label>
<div>
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
<div className="space-y-2">
{links.map((l, i) => (
<div key={i} className="flex gap-2">
<Input
className="w-full"
value={l}
onChange={(e) => setLink(i, e.target.value)}
placeholder="https://…"
/>
<IconBtn
onClick={() => removeLink(i)}
title={t("downloads.edit.removeLink")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</div>
))}
</div>
<Button variant="link-muted" icon={<Plus />} onClick={addLink} className="mt-2 text-sm">
{t("downloads.edit.addLink")}
</Button>
</div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<ModalFooter>
<Button
variant="primary"
size="sm"
onClick={() => save.mutate()}
disabled={save.isPending}
>
{t("downloads.edit.save")}
</Button>
</ModalFooter>
</div>
</Modal>
);
}
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
// unsupported-host/…), where a retry can't help, or need a fix outside this window first (sign-in: a
// cookie in Settings — its copy then sends the user to the row) — the queue row still keeps its own
// retry control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
// `site_changed` is here: the site answered in a way the downloader did not understand, which a
// downloader update on the server cures — the same link then goes through on a retry.
const RETRYABLE_ERRORS = ["postprocess", "network", "convert_failed", "site_changed"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
export function ErrorModal({
job,
onRetry,
onClose,
}: {
job: DownloadJob;
onRetry: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const code = job.error_code || "unknown";
const [showRaw, setShowRaw] = useState(false);
const retryable = RETRYABLE_ERRORS.includes(code);
return (
<Modal initialFocus="close" title={t(failTitleKey(job))} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(failCopyKeys(job, "short"))}</div>
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
{job.error && (
<div>
<Button
variant="link-muted"
aria-expanded={showRaw}
onClick={() => setShowRaw((v) => !v)}
className="text-xs"
>
{t("downloads.errors.fail.detailsToggle")}
</Button>
{showRaw && (
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
{job.error}
</pre>
)}
</div>
)}
<ModalFooter>
{retryable && (
<Button
variant="primary"
size="sm"
onClick={() => {
onRetry();
onClose();
}}
>
{t("downloads.actions.retry")}
</Button>
)}
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.close")}
</Button>
</ModalFooter>
</div>
</Modal>
);
}
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
//
// A job's picture (the Files list and grid): with none at all, a quiet glyph of what it is; a remote
// thumbnail that fails falls back to the same glyph. i18n is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
import { JobThumb } from "./downloadRow";
import type { DownloadJob } from "../../lib/api";
const baseJob: DownloadJob = {
id: 1,
status: "done",
progress: 100,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
error_detail: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: "abc",
source_url: null,
poster_url: null,
profile_id: null,
spec: {
mode: "av",
max_height: null,
container: null,
audio_format: null,
vcodec: null,
embed_subs: false,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: false,
},
display_name: "Clip",
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: true,
folder_id: null,
kept: false,
expires_at: null,
asset: null,
};
const withAsset = (
container: string | null,
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
): DownloadJob => ({
...baseJob,
asset: {
id: 10,
status: "ready",
title: "Clip",
uploader: null,
uploader_url: null,
upload_date: null,
duration_s: 90,
size_bytes: 148_800_000,
width: 1920,
height: 1080,
container,
media_kind: "video",
thumbnail_url: null,
...over,
},
});
afterEach(cleanup);
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
describe("JobThumb · no picture", () => {
it("draws a film glyph for a video, a note for audio", () => {
const { container, unmount } = render(<JobThumb job={withAsset("mp4")} />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
unmount();
const { container: audio } = render(
<JobThumb job={withAsset("m4a", { media_kind: "audio" })} />,
);
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
});
// The remote thumbnail was the last resort with no fallback of its own — when it
// failed too (a dead CDN link), the row drew the browser's broken-image mark.
it("falls back to the glyph when the remote picture fails too", () => {
const { container } = render(
<JobThumb job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
);
const img = container.querySelector("img")!;
expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg");
fireEvent.error(img);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
});
});
// A file that is not media: an image is its own picture (its sandboxed preview), unless it is too big
// for a thumbnail or fails; anything else draws the file glyph.
describe("JobThumb · a file", () => {
const image = (over: Partial<NonNullable<DownloadJob["asset"]>> = {}) =>
withAsset("png", { media_kind: "file", preview: "image", size_bytes: 200_000, ...over });
it("shows an image through its preview", () => {
const { container } = render(<JobThumb job={image()} />);
expect(new URL(container.querySelector("img")!.src).pathname).toBe("/api/files/1/preview");
});
it("keeps the glyph for an image too big to be a thumbnail, and for other files", () => {
const { container, unmount } = render(<JobThumb job={image({ size_bytes: 50_000_000 })} />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
unmount();
const { container: pdf } = render(
<JobThumb job={withAsset("pdf", { media_kind: "file", preview: "pdf" })} />,
);
expect(pdf.querySelector("img")).toBeNull();
});
it("falls back to the glyph when the image fails", () => {
const { container } = render(<JobThumb job={image()} />);
fireEvent.error(container.querySelector("img")!);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
});
});
@@ -0,0 +1,158 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react";
import clsx from "clsx";
import { DOWNLOAD_PHASES } from "../../lib/downloadQueue";
import { api, type DownloadJob } from "../../lib/api";
import { IconButton } from "../../components/ui/IconButton";
import { MediaProgress } from "../../components/ui/MediaCard";
import RemoteImage from "../../components/ui/RemoteImage";
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
// details dialog the icon button.
const STATUS_CLS: Record<string, string> = {
queued: "text-muted",
running: "text-accent",
paused: "text-warn",
done: "text-success",
error: "text-danger",
canceled: "text-muted",
};
export function StatusPill({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
// confused by the bar resetting between the separate video and audio streams.
if (job.status === "running" && job.phase && DOWNLOAD_PHASES.includes(job.phase)) {
return (
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
);
}
return (
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
{t(`downloads.status.${job.status}`)}
</span>
);
}
// A picture's box when the caller gives none: a thumbnail-sized 16:9 frame.
const THUMB_BOX =
"w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
/** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes
* it (the Files list draws a smaller one). */
// An image larger than this keeps the file glyph: a row's thumbnail is the whole file, and a page of
// camera originals would pull hundreds of megabytes for pictures a few rem wide.
const IMAGE_THUMB_MAX_BYTES = 8 * 1024 * 1024;
/** An image file's own picture for its thumbnail, or null (not an image, not readable, too big). */
function imageThumbOf(job: DownloadJob): string | null {
const a = job.asset;
if (a?.preview !== "image" || !job.can_download) return null;
if ((a.size_bytes ?? Infinity) > IMAGE_THUMB_MAX_BYTES) return null;
return api.previewUrl(job.id);
}
export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) {
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
const remote = job.asset?.thumbnail_url ?? null;
// `job.poster_url` only SIGNALS a poster exists; the <img> src is api.posterImageUrl.
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
// a neutral file glyph instead of an (always-empty) thumbnail. A preview that fails is an answer
// (gone since, or not an image after all), not a hiccup: the glyph at once, no retries.
if (job.asset?.media_kind === "file") {
const glyph = <File className="w-8 h-8" aria-hidden="true" />;
return (
<div className={`${box} flex items-center justify-center text-muted`}>
<RemoteImage
src={imageThumbOf(job)}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
retries={0}
fallback={glyph}
/>
</div>
);
}
// How far into it the viewer is, the media cards' bar.
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
// No picture at all (a link that failed before its details, a poster that never came): a quiet
// glyph of what it is, like the file glyph above — an empty box read as "still loading".
const glyph = (
<div className="flex h-full items-center justify-center text-muted">
{job.asset?.media_kind === "audio" ? (
<Music className="w-7 h-7" aria-hidden="true" />
) : (
<Film className="w-7 h-7" aria-hidden="true" />
)}
</div>
);
// The source's picture, the poster's fallback: retried like any remote picture, then the glyph.
const remotePicture = (
<RemoteImage
src={remote}
alt=""
loading="lazy"
className="w-full h-full object-cover"
fallback={glyph}
/>
);
return (
<div className={`${box} relative`}>
<MediaProgress pct={pct} />
{poster ? (
// Our own poster failing is an answer (the file is missing), so straight to the source's
// picture. RemoteImage remembers the failure by URL, so a recycled row never shows one job's
// fallback over the next one's poster, and forgets it once the row moves on — a poster that
// 404'd while its file was still being written is asked for again when the row comes back.
<RemoteImage
src={poster}
alt=""
loading="lazy"
className="w-full h-full object-cover"
retries={0}
fallback={remotePicture}
/>
) : (
remotePicture
)}
</div>
);
}
export function IconBtn({
onClick,
title,
danger,
open,
children,
}: {
onClick: () => void;
title: string;
danger?: boolean;
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
open?: boolean;
children: ReactNode;
}) {
return (
<IconButton
variant={danger ? "danger" : "ghost"}
size="md"
onClick={onClick}
data-nav-open={open ? "" : undefined}
label={title}
>
{children}
</IconButton>
);
}
@@ -0,0 +1,23 @@
import type { DownloadJob } from "../../lib/api";
// A download job's display title (a user rename wins over the extracted asset title), shared by the
// tray, the Files list, the admin Storage tab and the library player.
// With no name yet — a link queued before its details arrive, or one that failed first — it is the
// link itself, shortened the way the row shows a link, never the raw ref (a URL, or a bare video id).
export function jobTitle(job: DownloadJob): string {
return (
job.display_name ||
job.asset?.title ||
(job.source_url ? displayUrl(job.source_url) : job.source_ref)
);
}
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
function displayUrl(url: string): string {
try {
const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, "");
} catch {
return url;
}
}
@@ -0,0 +1,114 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
import { api, type DownloadJob, type DownloadJobKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { invalidateDownloads } from "../../lib/downloads";
import { useConfirm } from "../../components/ui/confirmContext";
import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups";
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
// sight; the copies and Cancel live here. A finished download is a library file: its actions are
// the Files page's (useFileActions).
export type JobOp = "pause" | "resume" | "cancel";
// The Cancel confirmation of a queued job; an upload's names what it deletes.
const CANCEL_DOWNLOAD = {
title: "downloads.confirm.cancelTitle",
body: "downloads.confirm.cancelBody",
action: "downloads.confirm.cancelAction",
};
const CANCEL_UPLOAD = {
title: "downloads.confirm.cancelUploadTitle",
body: "downloads.confirm.cancelUploadBody",
action: "downloads.confirm.cancelUploadAction",
};
const CANCEL_EDIT = {
title: "downloads.confirm.cancelEditTitle",
body: "downloads.confirm.cancelEditBody",
action: "downloads.confirm.cancelEditAction",
};
// Cancelling an upload deletes the file it uploaded (there is nothing to fetch again), so its
// confirmation says so — "stop the download" would read as safe to undo. A clip is being cut, not
// downloaded.
const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
download: CANCEL_DOWNLOAD,
upload: CANCEL_UPLOAD,
edit: CANCEL_EDIT,
clip: CANCEL_EDIT, // both are a clip being cut: the same words
};
export function useDownloadActions() {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const copy = useCopyItems();
const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: JobOp }) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
function queueItems(job: DownloadJob): ActionMenuItem[] {
const running = job.status === "queued" || job.status === "running";
return menuGroups(
[
running
? {
id: "pause",
label: t("downloads.actions.pause"),
icon: icon(Pause),
onSelect: () => act.mutate({ id: job.id, op: "pause" }),
}
: {
id: "resume",
label: t(
job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume",
),
icon: icon(job.status === "error" ? RefreshCw : Play),
onSelect: () => act.mutate({ id: job.id, op: "resume" }),
},
],
copies(job),
[
{
id: "cancel",
label: t("downloads.actions.cancel"),
icon: icon(Ban),
danger: true,
onSelect: () => {
const copy = CANCEL_COPY[job.job_kind ?? "download"];
void confirmThen(t(copy.title), t(copy.body), t(copy.action), () =>
act.mutate({ id: job.id, op: "cancel" }),
);
},
},
],
);
}
/** The job's menu rows. */
const items = (job: DownloadJob): ActionMenuItem[] => queueItems(job);
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
return { items, run };
}
@@ -0,0 +1,256 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../../lib/useActiveFilters";
import { decideChipLayout } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { Button } from "../../components/ui/Button";
import { Chip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { ChipLabelPending } from "../../components/ui/Skeleton";
import { SURFACE } from "../../components/ui/surfaces";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
// open the panel just to see what you'd filtered by.
//
// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
// so the chips must never push those off-screen. When they'd overflow it trims responsively:
// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
// back into the measurement and oscillate).
// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
// A chip's text: its name, or a placeholder while the name is on its way. The visible chip, the
// overflow popover's row and the hidden measuring mirror all draw it, so the mirror measures what shows.
function ChipText({ f, className }: { f: ActiveFilter; className: string }) {
return f.pending ? (
<ChipLabelPending value={f.label} />
) : (
<span className={className}>{f.label}</span>
);
}
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
<Chip
variant="token"
size="md"
data-testid={testId}
data-filter-key={f.key}
onClick={f.remove}
removeLabel={t("feed.chips.remove", { name: f.label })}
>
<ChipText f={f} className="max-w-56 truncate" />
</Chip>
);
}
// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
function OverflowPopover({
label,
ariaLabel,
chips,
onClearAll,
icon,
}: {
label: string;
/** Accessible name for the trigger + popover — describes what's inside (e.g. "3 more filters"),
* since the visible label can be a bare "+3". Defaults to `label`. */
ariaLabel?: string;
chips: ActiveFilter[];
onClearAll: () => void;
icon?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
const { setReference } = pop.refs;
const name = ariaLabel ?? label;
return (
<>
<Chip
variant="token"
size="md"
ref={setReference}
{...pop.getReferenceProps()}
aria-label={name}
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
>
{label}
</Chip>
<PopoverSurface
popover={pop}
ariaLabel={name}
className={`${SURFACE.menu} min-w-48 max-w-72 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
{chips.map((f) => (
<PopoverItem
key={f.key}
role="menuitem"
onSelect={f.remove}
label={f.label}
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
>
<ChipText f={f} className="flex-1 truncate" />
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
</PopoverItem>
))}
</div>
<div className="mt-1 border-t border-border px-1 pt-1.5">
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="text-xs underline underline-offset-2"
>
{t("sidebar.clearAll")}
</Button>
</div>
</PopoverSurface>
</>
);
}
export default function ActiveFilterChips({
filters,
onClearAll,
className,
}: {
filters: ActiveFilter[];
onClearAll: () => void;
className?: string;
}) {
const { t } = useTranslation();
const laneRef = useRef<HTMLDivElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
const plusRef = useRef<HTMLSpanElement | null>(null);
const clearRef = useRef<HTMLSpanElement | null>(null);
const n = filters.length;
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
const chipsKey = filters.map((f) => f.key + f.label + (f.pending ? "…" : "")).join("|");
// The shared measurement core hands over the chip right edges + gap + pill width + lane width; this
// policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear
// all" button's width, reserved only when it renders — n > 1) before deciding all/some/count.
const layout = useChipOverflowFit<{ mode: "all" | "some" | "count"; visible: number }>({
rowRef: laneRef,
chipsRef: chipRefs,
pillRef: plusRef,
deps: `${n}|${chipsKey}`,
initial: { mode: "all", visible: n },
decide: (g) => {
const labelEl = labelRef.current;
const labelRight = labelEl ? labelEl.offsetLeft + labelEl.offsetWidth : 0;
const clearW = n > 1 ? g.gap + (clearRef.current?.offsetWidth ?? 0) : 0;
return decideChipLayout({
n,
labelRight,
chipRights: g.chipRights,
plusW: g.pillW,
clearW,
avail: g.avail,
gap: g.gap,
narrowPx: NARROW_PX,
});
},
});
if (n === 0) return null;
const hiddenCount = n - layout.visible;
const overflowChips = filters.slice(layout.visible);
return (
<div
ref={laneRef}
data-testid="active-filter-chips"
className={`${chipLaneClass} flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
aria-label={t("feed.chips.label")}
>
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
and without test ids so it never doubles the visible chip count. */}
<div
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5"
>
<span ref={labelRef} className="text-xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f, i) => (
<span
key={f.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className={chipClass("token", "md") + " pr-1.5"}
>
<ChipText f={f} className="max-w-56 truncate" />
<X className="size-3" />
</span>
))}
<span ref={plusRef} className={chipClass("token", "md")}>
+{n}
</span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
{t("sidebar.clearAll")}
</span>
</div>
{/* Visible lane. */}
{layout.mode === "count" ? (
<OverflowPopover
label={t("feed.chips.count", { count: n })}
chips={filters}
onClearAll={onClearAll}
icon
/>
) : (
<>
<span className="text-xs uppercase tracking-wider text-muted mr-0.5 shrink-0">
{t("feed.chips.label")}
</span>
{filters.slice(0, layout.visible).map((f) => (
<ChipButton key={f.key} f={f} testId="active-filter-chip" />
))}
{layout.mode === "some" && hiddenCount > 0 && (
<OverflowPopover
label={`+${hiddenCount}`}
ariaLabel={t("feed.chips.moreCount", { count: hiddenCount })}
chips={overflowChips}
onClearAll={onClearAll}
/>
)}
{layout.mode === "all" && n > 1 && (
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 text-sm underline underline-offset-2"
>
{t("sidebar.clearAll")}
</Button>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,240 @@
// @vitest-environment jsdom
//
// The Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
// real modal against a mocked savedViewsContext so we can assert exactly which mutation fires; the
// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so
// the prune snapshots are the genuine ones the live chip row computes.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
updateFilters: vi.fn(async (_id: number, _filters: Record<string, unknown>) => true),
saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record<string, unknown> | null),
rename: vi.fn(async (_id: number, _name: string) => true),
activeViewId: null as number | null,
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string }) =>
key === "common.order.dragNamed" ? `${key}:${o?.name}` : key,
}),
};
});
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
// surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as
// "active" and spray spurious chips.
const VIEW_FILTERS = {
tags: [1],
tagModes: { language: "or", topic: "or", other: "or" },
untagged: false,
channelIds: [],
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
scope: "my",
q: "",
};
const CTX = {
source: "feed" as const,
views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }],
loading: false,
displayView: null,
isDirty: false,
modalOpen: true,
openModal: vi.fn(),
closeModal: vi.fn(),
apply: vi.fn(),
clearActive: vi.fn(),
create: vi.fn(async () => true),
reorder: vi.fn(async () => {}),
remove: vi.fn(async () => true),
toggleDefault: vi.fn(async () => {}),
};
vi.mock("../../modules/feed/savedViewsContext", () => ({
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
updateFilters: h.updateFilters,
saveCurrent: h.saveCurrent,
rename: h.rename,
}),
// Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder.
asFilters: (raw: Record<string, unknown>) => ({
...raw,
tags: (raw.tags as unknown[]) ?? [],
channelIds: (raw.channelIds as unknown[]) ?? [],
}),
}));
import PersonalViewsModal from "./PersonalViewsModal";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]);
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never);
const utils = render(
<QueryClientProvider client={qc}>
<PersonalViewsModal />
</QueryClientProvider>,
);
return { ...utils, qc };
}
beforeEach(() => {
h.updateFilters.mockClear();
h.saveCurrent.mockClear();
h.rename.mockClear();
h.activeViewId = null;
// Fresh baseline each test — one test mutates CTX.views to simulate a refetch.
CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }];
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("PersonalViewsModal — editable detail pane", () => {
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
mount();
const removeBtn = await screen.findByLabelText("feed.chips.remove");
fireEvent.click(removeBtn);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
expect(h.updateFilters.mock.calls[0]![0]).toBe(5);
expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] });
});
it("the prune is undoable — Undo re-persists the original filters", async () => {
mount();
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
// The undo control only appears once there is history.
const undo = await screen.findByLabelText("common.undo");
fireEvent.click(undo);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
});
it("a same-view refetch (new-identity filters) does not wipe the undo history", async () => {
// Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the
// real provider refetches and hands back the SAME view id with a fresh-identity filters object. The
// draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps
// regression that added selected.filters would make this fail.
const { rerender, qc } = mount();
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
expect(await screen.findByLabelText("common.undo")).toBeTruthy();
// Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value.
CTX.views = [
{
id: 5,
name: "Docs",
filters: { ...VIEW_FILTERS, tags: [] },
position: 0,
is_default: false,
},
];
rerender(
<QueryClientProvider client={qc}>
<PersonalViewsModal />
</QueryClientProvider>,
);
// Undo history survived the refetch — the control is still there and still re-persists on click.
const undo = await screen.findByLabelText("common.undo");
fireEvent.click(undo);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
});
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
mount();
fireEvent.click(await screen.findByText("views.saveCurrent"));
await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5));
});
it("Save-current is disabled when the selected view is already the active one", async () => {
h.activeViewId = 5;
mount();
const btn = (await screen.findByText("views.saveCurrent")).closest(
"button",
) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
fireEvent.click(btn);
expect(h.saveCurrent).not.toHaveBeenCalled();
});
it("rename commits via the visible check button once the name changes", async () => {
mount();
const input = await screen.findByDisplayValue("Docs");
// No change yet → the commit button is disabled.
const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement;
expect(commit.disabled).toBe(true);
fireEvent.change(input, { target: { value: "Documentaries" } });
expect(commit.disabled).toBe(false);
fireEvent.click(commit);
await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries"));
});
});
describe("PersonalViewsModal — reordering without a drag (WCAG 2.5.7)", () => {
const view = (id: number, name: string, position: number) => ({
id,
name,
filters: VIEW_FILTERS,
position,
is_default: false,
});
it("moves the selected view with its order buttons, the moves that go nowhere held off", async () => {
CTX.reorder.mockClear();
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1), view(7, "Tech", 2)];
mount();
const group = await screen.findByRole("group", { name: /common\.order\.label/ });
const btn = (name: string) =>
Array.from(group.querySelectorAll("button")).find(
(b) => b.getAttribute("aria-label") === name,
)!;
expect(btn("common.order.top").disabled).toBe(true);
expect(btn("common.order.up").disabled).toBe(true);
fireEvent.click(btn("common.order.down"));
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 5, 7]);
fireEvent.click(btn("common.order.bottom"));
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 7, 5]);
// A view picked in the list moves from ITS place, not the first one's.
fireEvent.click(screen.getByRole("button", { name: "Tech" }));
await waitFor(() => expect(btn("common.order.up").disabled).toBe(false));
fireEvent.click(btn("common.order.up"));
expect(CTX.reorder).toHaveBeenLastCalledWith([5, 7, 6]);
});
it("has no order buttons for a single view", async () => {
mount();
await screen.findByDisplayValue("Docs");
expect(screen.queryByRole("group", { name: /common\.order\.label/ })).toBeNull();
});
it("picks a view up from the keyboard, as its grip's instructions say", async () => {
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
mount();
const grip = await screen.findByRole("button", { name: "common.order.dragNamed:Docs" });
grip.focus();
fireEvent.keyDown(grip, { code: "Space", key: " " });
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
});
});
@@ -0,0 +1,382 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
coercePlexFilters,
type FeedFilters,
type PlexFilters,
type SavedView,
} from "../../lib/api";
import Modal from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { useSavedViews, asFilters } from "../../modules/feed/savedViewsContext";
import { useActiveFilters } from "../../lib/useActiveFilters";
import { buildPlexActiveFilters } from "../../modules/plex/usePlexActiveFilters";
import { useUndoable } from "../../lib/useUndoable";
import UndoToolbar from "../../components/ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle";
import { OrderButtons } from "../../components/ui/OrderButtons";
import { Pressable } from "../../components/ui/Pressable";
import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder, or the selected one's order buttons on the right) plus a "save the current filters" input;
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
// all mutations flow through SavedViewsProvider.
function ViewRow({
view,
selected,
active,
onSelect,
}: {
view: SavedView;
selected: boolean;
active: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: view.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
selected ? "bg-accent/15" : "state-layer"
}`}
>
<DragHandle
label={t("common.order.dragNamed", { name: view.name })}
{...attributes}
{...listeners}
/>
<Pressable
selectable
onClick={onSelect}
className={`flex-1 min-w-0 truncate py-1.5 text-sm ${selected ? "text-accent" : "text-fg"}`}
>
{view.name}
</Pressable>
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
</div>
);
}
/** The SELECTED view's two drafts: its name (the rename field) and a local, undoable draft of its
* captured filters. Removing a chip prunes the filter draft and — via onApply — writes the pruned set
* straight back to the stored view; Undo/Redo re-persist the same way. The draft is the single
* source of truth for the chip row, so a chip-prune refetch (which returns the value we just wrote)
* never clobbers the undo history: both re-seed only when the SELECTION changes (and on Save-current,
* by the caller). Adjusted DURING render against the remembered selection — an effect painted the
* previous view's filters and name for a frame first. `selectedId` is the trigger, `selected` only
* the data. */
function useSelectedViewDrafts(
selected: SavedView | null,
selectedId: number | null,
updateFilters: (id: number, filters: Record<string, unknown>) => unknown,
) {
const [nameDraft, setNameDraft] = useState("");
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
onApply: (f) => {
if (selected) void updateFilters(selected.id, f);
},
});
const [seededFor, setSeededFor] = useState<number | null | undefined>(undefined);
if (seededFor !== selectedId) {
setSeededFor(selectedId);
filtersDraft.reset(selected?.filters ?? {});
setNameDraft(selected?.name ?? "");
}
return { filtersDraft, nameDraft, setNameDraft };
}
export default function PersonalViewsModal() {
const { t } = useTranslation();
const {
source,
views,
activeViewId,
apply,
create,
rename,
updateFilters,
saveCurrent,
toggleDefault,
remove,
reorder,
closeModal,
} = useSavedViews();
// The grip's keyboard pick-up (space, the arrows, space) is what its screen-reader instructions
// promise; without the sensor they described a gesture the list did not have.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
const selected = views.find((v) => v.id === selectedId) ?? null;
// Two independent name fields: the left one names a NEW view; the right one renames the selected one.
const [createName, setCreateName] = useState("");
const { filtersDraft, nameDraft, setNameDraft } = useSelectedViewDrafts(
selected,
selectedId,
updateFilters,
);
// The selected view's captured filters, as the same human labels its module's chip row shows, read
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
// uses the pure builder. A plex view captures its rail filters, not the watch-state (show="all").
const noop = () => {};
const setDraftFeed = (f: FeedFilters) =>
filtersDraft.set(f as unknown as Record<string, unknown>);
const feedCaptured = useActiveFilters(
source === "feed" && selected
? asFilters(filtersDraft.value as unknown as FeedFilters)
: ({ tags: [], channelIds: [] } as never),
source === "feed" && selected ? setDraftFeed : noop,
);
const patchDraftPlex = (p: Partial<PlexFilters>) =>
filtersDraft.set({
...coercePlexFilters(filtersDraft.value),
...p,
} as unknown as Record<string, unknown>);
const captured =
source === "plex" && selected
? buildPlexActiveFilters(
coercePlexFilters(filtersDraft.value),
"all",
patchDraftPlex,
noop,
t,
)
: feedCaptured;
const dndA11y = useSortableA11y((id) => ({
title: views.find((v) => v.id === id)?.name ?? "",
pos: views.findIndex((v) => v.id === id) + 1,
total: views.length,
}));
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const ids = views.map((v) => v.id);
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
await reorder(next);
}
// The non-drag way (WCAG 2.5.7): the selected view's Top / Up / Down / Bottom, beside its editor.
const selectedAt = views.findIndex((v) => v.id === selectedId);
function moveSelected(to: number) {
const ids = views.map((v) => v.id);
void reorder(arrayMove(ids, selectedAt, to));
}
async function onCreate() {
const name = createName.trim();
if (!name) return;
if (await create(name)) setCreateName("");
}
const nameChanged = !!selected && nameDraft.trim() !== "" && nameDraft.trim() !== selected.name;
async function onRename() {
if (!selected || !nameChanged) return;
await rename(selected.id, nameDraft.trim());
}
// Save-current is a no-op when the live filters already equal this exact view (it's the active one).
const saveCurrentDisabled = selected != null && selected.id === activeViewId;
return (
<Modal
initialFocus="close"
title={t("views.manageTitle")}
onClose={closeModal}
maxWidth="max-w-2xl"
>
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
{/* Left: the list + create */}
<div className="flex flex-col border-r border-border/60 pr-4">
<div className="flex-1 space-y-1 overflow-y-auto">
{views.length === 0 && (
<p className="px-1 py-2 text-2xs leading-snug text-muted">{t("views.empty")}</p>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
accessibility={dndA11y}
>
<SortableContext
items={views.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
selected={v.id === selectedId}
active={v.id === activeViewId}
onSelect={() => setSelectedId(v.id)}
/>
))}
</SortableContext>
</DndContext>
</div>
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
<Input
className="flex-1"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onCreate()}
placeholder={t("views.namePlaceholder")}
/>
<IconButton
label={t("views.save")}
size="sm"
variant="primary"
onClick={onCreate}
disabled={!createName.trim()}
>
<BookmarkPlus className="h-4 w-4" />
</IconButton>
</div>
</div>
{/* Right: the selected view's detail */}
{selected ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1.5">
<Input
className="flex-1 font-medium"
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void onRename()}
onBlur={() => void onRename()}
/>
<IconButton
variant="outline"
size="md"
onClick={() => void onRename()}
disabled={!nameChanged}
label={t("views.rename")}
>
<Check />
</IconButton>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-2xs font-semibold uppercase tracking-wider text-muted">
{t("views.capturedFilters")}
</span>
{(filtersDraft.canUndo || filtersDraft.canRedo) && (
<UndoToolbar
canUndo={filtersDraft.canUndo}
canRedo={filtersDraft.canRedo}
onUndo={filtersDraft.undo}
onRedo={filtersDraft.redo}
/>
)}
</div>
{captured.length === 0 ? (
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{captured.map((f) => (
<DismissibleChip
key={f.key}
size="xs"
onDismiss={f.remove}
dismissLabel={t("feed.chips.remove", { name: f.label })}
>
{f.label}
</DismissibleChip>
))}
</div>
)}
</div>
{views.length > 1 && (
<OrderButtons from={selectedAt} count={views.length} onMove={moveSelected} />
)}
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
<Button
variant="primary"
size="sm"
icon={<Play className="h-3.5 w-3.5" />}
onClick={() => apply(selected)}
>
{t("views.apply")}
</Button>
<Button
variant="outline"
size="sm"
icon={<Save />}
onClick={async () => {
const saved = await saveCurrent(selected.id);
// Re-seed the draft to what we just stored (the live filters) so the chip row
// reflects the save; clears the now-stale undo history.
if (saved) filtersDraft.reset(saved);
}}
disabled={saveCurrentDisabled}
title={
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
}
>
{t("views.saveCurrent")}
</Button>
<Button
variant="outline"
size="sm"
pressed={selected.is_default}
onClick={() => void toggleDefault(selected)}
icon={<Star className={selected.is_default ? "fill-current" : ""} />}
>
{t("views.default")}
</Button>
<Button
variant="danger-soft"
size="sm"
icon={<Trash2 />}
className="ml-auto"
onClick={async () => {
const next = views.find((v) => v.id !== selected.id);
// Only move the selection if the delete actually happened (remove awaits a confirm
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
if (await remove(selected)) setSelectedId(next?.id ?? null);
}}
>
{t("views.delete")}
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center text-sm text-muted">
{t("views.selectPrompt")}
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// "Manage views…" opens a dialog from a menu row, so it must close the menu FIRST and open the dialog
// on a fresh task (`closeThenAct`): the menu's focus return, queued as it unmounts, otherwise takes the
// dialog's focus back to the trigger behind its scrim — measured in Chrome on ActionMenu, and
// this row was the sibling that fix missed. jsdom does not run that focus
// race, so the ORDER is what is pinned: the menu is gone before the dialog is asked to open.
import { beforeAll, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
const openModal = vi.fn();
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../../modules/feed/savedViewsContext", () => ({
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
}));
import SavedViewsPopover from "./SavedViewsPopover";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
describe("SavedViewsPopover · Manage views…", () => {
it("closes the menu first, then opens the dialog once", async () => {
render(
<FloatingTree>
<SavedViewsPopover trigger="views" ariaLabel="Saved views" />
</FloatingTree>,
);
fireEvent.click(screen.getByRole("button", { name: "Saved views" }));
// floating-ui never positions in jsdom, so the surface stays visibility:hidden and the row has no
// accessible name to query by: find it by its text.
fireEvent.click(screen.getByText("views.manage"));
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
expect(openModal).not.toHaveBeenCalled();
await vi.waitFor(() => expect(openModal).toHaveBeenCalledTimes(1));
});
});
// The toolbar chip's glyph sat INSIDE the value text, and an <svg> is display:block under the
// preflight, so the chip broke onto two lines (the bookmark above "gaming ▾"). The glyph and the
// modified dot ride in the trigger's own slots; the value is a row.
describe("SavedViewsPopover · toolbar chip", () => {
it("puts the glyph in the trigger's icon slot, not inside the value", () => {
render(
<FloatingTree>
<SavedViewsPopover
toolbar
icon={<svg data-testid="glyph" />}
trigger={<span>gaming</span>}
ariaLabel="View chip"
/>
</FloatingTree>,
);
const button = screen.getByRole("button", { name: "View chip" });
expect(screen.getByTestId("glyph").parentElement).toBe(button);
expect(screen.getByText("gaming").parentElement!.className).toContain("flex");
});
});
@@ -0,0 +1,106 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { useSavedViews } from "../../modules/feed/savedViewsContext";
import { IconButton } from "../../components/ui/IconButton";
import { MenuCheck } from "../../components/ui/MenuCheck";
import { SURFACE } from "../../components/ui/surfaces";
import { SelectTrigger } from "../../components/ui/SelectTrigger";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
// checked) + a "Manage views…" entry that opens the Personal Views modal, where the editing lives.
export default function SavedViewsPopover({
trigger,
toolbar = false,
icon,
trailing,
ariaLabel,
placement = "bottom-start",
}: {
trigger: ReactNode;
/** The toolbar's view chip: the A5 dropdown trigger around `trigger`, instead of a glyph. */
toolbar?: boolean;
/** Toolbar only: the trigger's leading glyph and its after-the-value mark, in their own slots. */
icon?: ReactNode;
trailing?: ReactNode;
ariaLabel?: string;
placement?: "bottom-start" | "bottom-end";
}) {
const { t } = useTranslation();
const { views, activeViewId, apply, openModal } = useSavedViews();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement });
const { setReference } = pop.refs;
const label = ariaLabel ?? t("views.saved");
return (
<>
{toolbar ? (
<SelectTrigger
context="toolbar"
open={open}
icon={icon}
trailing={trailing}
ref={setReference}
{...pop.getReferenceProps()}
aria-label={label}
>
{trigger}
</SelectTrigger>
) : (
<IconButton
variant="ghost-accent"
ref={setReference}
{...pop.getReferenceProps()}
label={label}
>
{trigger}
</IconButton>
)}
<PopoverSurface
popover={pop}
ariaLabel={label}
className={`${SURFACE.menu} min-w-56 animate-[popIn_0.16s_ease]`}
>
{views.length === 0 ? (
<p className="px-2 py-1.5 text-2xs leading-snug text-muted">{t("views.empty")}</p>
) : (
<div role="group" aria-label={label}>
{views.map((v) => (
<PopoverItem
key={v.id}
role="menuitemradio"
checked={v.id === activeViewId}
onSelect={() => {
apply(v);
setOpen(false);
}}
label={v.name}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
v.id === activeViewId ? "text-fg" : "text-muted"
}`}
>
<MenuCheck kind="one" on={v.id === activeViewId} />
<span className="flex-1 truncate">{v.name}</span>
</PopoverItem>
))}
</div>
)}
<div className="mt-1 border-t border-border pt-1">
<PopoverItem
role="menuitem"
// Opens a dialog: close first, or the menu's focus return steals its focus (closeThenAct).
onSelect={() => closeThenAct(() => setOpen(false), openModal)}
label={t("views.manage")}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
>
<Settings2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="flex-1">{t("views.manage")}</span>
</PopoverItem>
</div>
</PopoverSurface>
</>
);
}
@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next";
import { Bookmark } from "lucide-react";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "../../modules/feed/savedViewsContext";
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
// from the saved view. Clicking it opens the saved-views picker to switch views. Renders nothing when
// no view is active (nothing applied and the filters match no saved view).
export default function SelectedViewChip() {
const { t } = useTranslation();
const { displayView, isDirty } = useSavedViews();
if (!displayView) return null;
return (
<SavedViewsPopover
ariaLabel={t("views.saved")}
toolbar
icon={<Bookmark className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />}
trailing={
isDirty && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn"
title={t("views.modified")}
aria-label={t("views.modified")}
/>
)
}
trigger={<span className="block max-w-40 truncate">{displayView.name}</span>}
/>
);
}
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
//
// Ctrl/Cmd+K had no visible entry point (debt burn B28). The owner's pick: a key cap in the header's
// search field, and a "Jump to…" pill in its place on a page that has no search field.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
let page = "home";
vi.mock("../navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
vi.mock("../../modules/feed/feedFiltersContext", () => ({
useFeedFilters: () => ({ q: "", sort: "newest" }),
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
}));
vi.mock("../../modules/playlists/playlistsContext", () => ({
usePlaylists: () => ({ search: "" }),
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
}));
vi.mock("../../modules/channels/channelsContext", () => ({
useChannels: () => ({ search: "" }),
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
}));
vi.mock("../../modules/channels/SyncStatus", () => ({ default: () => null }));
import Header from "../../shell/Header";
import { closePalette, usePaletteOpen } from "../../lib/keymap";
import type { Me } from "../../lib/api";
afterEach(() => {
cleanup();
act(() => closePalette());
});
function mount() {
let open = false;
function Probe() {
open = usePaletteOpen();
return null;
}
render(
<>
<Header me={{ plex_enabled: false } as Me} onYtSearch={() => {}} />
<Probe />
</>,
);
return () => open;
}
describe("the command palette's visible entry", () => {
it("is a key cap inside the search field, naming the chord the palette row binds", () => {
page = "feed";
const isOpen = mount();
const cap = screen.getByRole("button", { name: /^palette\.open \((Ctrl|⌘) K\)$/ });
// Label in Name: the name carries what the cap shows, so "click Ctrl K" finds it.
expect(cap.getAttribute("aria-label")).toContain(cap.textContent);
// …and a tooltip equal to that name, so a screen reader does not read it again as a description.
expect(cap.getAttribute("title")).toBe(cap.getAttribute("aria-label"));
expect(screen.getByRole("textbox")).toBeTruthy();
fireEvent.click(cap);
expect(isOpen()).toBe(true);
});
it("is a Jump-to pill on a page with no search field", () => {
page = "home";
const isOpen = mount();
expect(screen.queryByRole("textbox")).toBeNull();
// Named by what it shows ("Jump to…" and its key cap), not by a label that replaced it.
const pill = screen.getByRole("button", { name: /^palette\.title ?(Ctrl|⌘) K$/ });
expect(pill.getAttribute("title")).toBe("palette.open");
fireEvent.click(pill);
expect(isOpen()).toBe(true);
});
});
@@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next";
import { Command } from "lucide-react";
import { SHORTCUTS, formatChord, openPalette } from "../../lib/keymap";
import { Pressable } from "../../components/ui/Pressable";
import clsx from "clsx";
import { fieldClass } from "../../components/ui/fieldStyles";
// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
// behind the other undiscoverable chord. The owner's pick (2026-09-19): a key cap where you already
// type, the header's search field; a page with no search field gets the same key cap on a small
// "Jump to…" pill in its place, so the entry is on every page.
/** The chord the palette row actually binds, read from the table rather than restated. */
const paletteChord = SHORTCUTS.find((s) => s.id === "palette")?.keys[0] ?? "Mod+k";
function useCap(): string {
const { t } = useTranslation();
return formatChord(paletteChord, {
Space: t("keys.cap.space"),
ContextMenu: t("keys.cap.menu"),
});
}
// Each accessible name CONTAINS what the control shows (WCAG 2.5.3, Label in Name): a voice-control
// user says what they see — "click Jump to", "click Ctrl K" — and a name that replaced the visible
// words matched nothing.
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
* too; left out of a phone's narrow header and of every touch screen, where there is (usually) no
* keyboard to press it on — on a tablet it was a 44 px box crowding the field. ⌘K still works. */
export function PaletteKeycap() {
const { t } = useTranslation();
const cap = useCap();
return (
<Pressable
onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`}
title={`${t("palette.open")} (${cap})`}
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg pointer-coarse:hidden @max-[640px]/appmain:hidden"
>
{cap}
</Pressable>
);
}
/** The same entry for a page whose header has no search field. */
export function PaletteLauncher() {
const { t } = useTranslation();
const cap = useCap();
return (
<Pressable
onClick={openPalette}
title={t("palette.open")}
// The search box's own frame (a 40 glass pill with the field edge): on a page without a search
// this is where the search would be, and it opens the same place.
className={clsx(
fieldClass({ size: "lg", pill: true, floating: true }),
"pointer-events-auto flex items-center gap-2 text-muted hover:text-fg",
)}
>
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{t("palette.title")}</span>
<kbd className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
{cap}
</kbd>
</Pressable>
);
}
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
//
// Locks the container-query classes on the shared search box that fix the narrow-header
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import SearchBar from "./SearchBar";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(cleanup);
describe("SearchBar responsive wrapper", () => {
it("carries the container-query classes that make it wrap instead of collapsing", () => {
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
const cls = (container.firstElementChild as HTMLElement).className;
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
expect(cls).toContain("@min-[420px]/appmain:min-w-[18rem]");
// On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
expect(cls).toContain("@max-[640px]/appmain:basis-full");
expect(cls).toContain("@max-[640px]/appmain:order-last");
});
});
// On a tablet the 44 px key cap and Go sat on the edge of a 44 px pill and crowded the text out.
describe("SearchBar on a touch screen", () => {
it("drops the key cap, and grows the pill past the 44 px Go so a ring shows round it", () => {
const { container, getByRole } = render(
<SearchBar value="" onChange={() => {}} placeholder="Search…" onGo={() => {}} />,
);
expect(getByRole("button", { name: /palette.open/ }).className).toContain(
"pointer-coarse:hidden",
);
const pill = container.querySelector("input")!.closest<HTMLElement>("[class*='h-10']")!;
expect(pill.className).toContain("pointer-coarse:h-13");
});
it("shortens Go to its icon by the column's width, not the screen's", () => {
const { getByText } = render(
<SearchBar
value=""
onChange={() => {}}
placeholder="Search…"
onGo={() => {}}
goIcon={<svg />}
goLabel="Go far"
/>,
);
const label = getByText("Go far");
expect(label.className).toContain("@min-[900px]/appmain:inline");
expect(label.className).not.toMatch(/(^|\s)md:inline/);
});
});
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
//
// `s` puts the caret in the module's search box from anywhere on the page, and Escape hands
// the keyboard back to the page — the way in needs a way out, or `j`/`k` end up typed into the query.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import SearchBar from "./SearchBar";
import KeyboardHelp from "../../components/ui/KeyboardHelp";
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(() => {
cleanup();
closeKeyboardHelp();
});
function press(init: KeyboardEventInit, on?: Element | null): KeyboardEvent {
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
act(() => {
(on ?? document.activeElement ?? document.body).dispatchEvent(e);
});
return e;
}
const field = () => screen.getByPlaceholderText("Search…") as HTMLInputElement;
describe("SearchBar keys", () => {
it("focuses the box on s, selects the old query, and keeps the s out of it", () => {
render(<SearchBar value="old query" onChange={() => {}} placeholder="Search…" />);
const e = press({ key: "s" });
expect(document.activeElement).toBe(field());
expect(e.defaultPrevented).toBe(true); // the `s` is not typed into the box it just focused
expect([field().selectionStart, field().selectionEnd]).toEqual([0, "old query".length]);
});
it("leaves an s typed into another field alone", () => {
render(
<>
<input data-testid="other" />
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
</>,
);
const other = screen.getByTestId("other");
act(() => other.focus());
expect(press({ key: "s" }).defaultPrevented).toBe(false);
expect(document.activeElement).toBe(other);
});
it("stays quiet under a dialog", () => {
render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
const id = pushLayer();
press({ key: "s" });
removeLayer(id);
expect(document.activeElement).not.toBe(field());
});
it("gives the keyboard back to the page on Escape", () => {
render(<SearchBar value="abc" onChange={() => {}} placeholder="Search…" />);
press({ key: "s" });
press({ key: "Escape" });
expect(document.activeElement).toBe(document.body);
});
// Plex's typeahead uses its first Escape to close the result panel; the field keeps focus for that.
it("lets a caller keep its own Escape", () => {
render(
<SearchBar
value="abc"
onChange={() => {}}
placeholder="Search…"
onKeyDown={(e) => e.key === "Escape" && e.preventDefault()}
/>,
);
press({ key: "s" });
press({ key: "Escape" });
expect(document.activeElement).toBe(field());
});
// Through the real hook: the sheet offers `s` only where a search box is mounted.
it("is listed in the help sheet while a search box is mounted", () => {
const { unmount } = render(
<>
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
<KeyboardHelp />
</>,
);
act(() => toggleKeyboardHelp());
expect(screen.getAllByRole("definition").map((d) => d.textContent)).toContain("keys.search");
unmount();
closeKeyboardHelp();
render(<KeyboardHelp />);
act(() => toggleKeyboardHelp());
expect(screen.getAllByRole("definition").map((d) => d.textContent)).not.toContain(
"keys.search",
);
});
// Opened over a player, the page keys are quiet — the sheet must not list them there.
it("is left off a sheet opened over a keyboard-owning layer", () => {
render(
<>
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
<KeyboardHelp />
</>,
);
const id = pushLayer();
act(() => toggleKeyboardHelp());
expect(screen.queryAllByRole("definition").map((d) => d.textContent)).not.toContain(
"keys.search",
);
removeLayer(id);
});
});
+133
View File
@@ -0,0 +1,133 @@
import clsx from "clsx";
import {
useRef,
type FocusEventHandler,
type InputHTMLAttributes,
type KeyboardEventHandler,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
import { useSearchFocusKey } from "../../lib/keymap";
import { PaletteKeycap } from "./PaletteLauncher";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { InnerInput } from "../../components/ui/Input";
import { fieldClass } from "../../components/ui/fieldStyles";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a 40px glass pill with the A4 field edge (the page's one hero
// field, the YouTube/Gmail shape), holding a search icon, the input, a clear button and a "Go"
// trigger (Enter also fires it). What sits inside the pill is round too, so the radii nest. The feed passes its live-YouTube
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
//
// Optional slots keep the box reusable while letting one module enrich it: `leading` replaces the
// search icon with a control (Plex's search scope-pill), and `below` renders an absolutely
// positioned panel attached under the box (Plex's instant typeahead results). `onFocus`/`onBlur`/
// `onKeyDown` pass through to the input so a caller can drive that panel (open on focus, arrow-key
// navigation); the built-in Enter→Go fires only if the caller didn't `preventDefault()` first.
export default function SearchBar({
value,
onChange,
placeholder,
onGo,
goLabel,
goIcon,
goDisabled,
goTitle,
leading,
below,
onFocus,
onBlur,
onKeyDown,
inputProps,
}: {
value: string;
onChange: (q: string) => void;
placeholder: string;
// Enter / the Go button call this. Omit to hide the Go button entirely.
onGo?: () => void;
goLabel?: string;
goIcon?: ReactNode;
goDisabled?: boolean;
goTitle?: string;
leading?: ReactNode;
below?: ReactNode;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
// Extra attributes spread onto the <input> (e.g. Plex's combobox ARIA). Spread FIRST so the
// component's own value/handlers always win.
inputProps?: InputHTMLAttributes<HTMLInputElement>;
}) {
const { t } = useTranslation();
// `s` puts the caret here from anywhere on the page — the header mounts exactly one box.
const inputRef = useRef<HTMLInputElement>(null);
useSearchFocusKey(inputRef);
const fire = () => {
if (onGo && !goDisabled) onGo();
};
// Responsive rules on the SHARED search box (every module's header, not just Plex), keyed on the
// content column's width via `@container/appmain`: `min-w-0` by default so it never forces
// overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
// the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
// a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it. The floor
// is the input's room plus what sits beside it (the icon, the key cap, Go): at 15rem the box kept
// only ~8rem for the text and a tablet cut the placeholder ("Search your subscriptior").
return (
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[18rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12.
On touch the pill grows past the 44 px Go so a ring of the pill shows round it — a 44 Go in a
44 pill sat on its edge. */}
<div
className={clsx(
fieldClass({ size: "free", pill: true, floating: true }),
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:h-13 pointer-coarse:pr-1 pointer-coarse:text-base",
)}
>
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
<InnerInput
{...inputProps}
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.key === "Enter") fire();
// Escape hands the keyboard back to the page, where `j`/`k` and the `g` chords live — `s`
// got you in, so there has to be a key that gets you out. The query stays as typed. A caller
// that uses Escape itself (Plex's typeahead closing its panel) preventDefaults first.
else if (e.key === "Escape") e.currentTarget.blur();
}}
placeholder={placeholder}
/>
{value && (
<IconButton round onClick={() => onChange("")} label={t("header.clearSearch")}>
<X />
</IconButton>
)}
<PaletteKeycap />
{onGo && (
<Button
size="sm"
round
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
>
{goIcon}
{/* By the column, not the screen: a tablet's 768–1024 px window has a tight column. */}
<span className={goIcon ? "hidden @min-[900px]/appmain:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</Button>
)}
</div>
{below}
</div>
);
}
@@ -0,0 +1,481 @@
// @vitest-environment jsdom
//
// The navigation actions against the overlay history stack: `setPage` and `openChannel` never push
// under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
// `setPage`'s same-page guard, which decides whether anything is pushed at all.
//
// The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
// palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
// actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
// 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, render as rtlRender, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
// Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the
// import graph pulls in `src/i18n`, which initialises against the real `initReactI18next` — so a
// wholesale mock throws before any test runs.
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
});
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings";
import { qk } from "../../lib/queryKeys";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
import { api } from "../../lib/api";
import { PAGE_KEY } from "./navigationInit";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
// The provider reads the signed-in account (the tab title) from the `me` cache.
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>
<NavigationProvider>{children}</NavigationProvider>
</QueryClientProvider>
);
const render = () =>
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
const settle = () =>
act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
/** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to
* land its popstate at once. */
async function openOverlay() {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
const holder: { unmount?: () => void } = {};
const onClose = vi.fn(() => holder.unmount?.());
const layer = renderHook(() => useBackToClose(onClose));
holder.unmount = layer.unmount;
await settle();
return { back, onClose };
}
beforeEach(() => {
qc = new QueryClient();
// An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here.
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
localStorage.clear();
claimLocalData(1); // the account's page memory
window.history.replaceState({ sfPage: "feed" }, "", "/");
});
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("NavigationProvider · the tab title", () => {
// Prod UAT 2026-09-19: signed out, the tab read the Home page's question over the sign-in form.
it("is the brand alone while nobody is signed in, the page's name once someone is", async () => {
window.history.replaceState({ sfPage: "home" }, "", "/?page=home");
qc.setQueryData(qk.me(), null);
render();
await settle();
expect(document.title).toBe("Siftlode");
act(() => {
qc.setQueryData(qk.me(), { id: 1 });
});
await settle();
expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true);
});
// 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");
});
// 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", () => {
it("pushes an entry for a real page change", () => {
const { result } = render();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
});
it("leaves every page's own URL keys behind, the activity page's included", () => {
window.history.replaceState(
{ sfPage: "activity" },
"",
"/?page=activity&at=logs&aw=24h&au=none&aq=x&rjob=backfill&rst=error&aact=config_set" +
"&lgrp=error&lreq=req-abcdef12&lrun=7&pscope=movie&ctab=hidden&keep=1",
);
const { result } = render();
act(() => result.current.setPage("plex"));
expect(window.location.search).toBe("?page=plex&keep=1");
});
it("pushes nothing when it is already that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(push).not.toHaveBeenCalled();
});
it("still navigates to the current page when a channel page is over it", () => {
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
const { result } = render();
// The page under test must be the one that is ALREADY current, or the guard's first conjunct is
// false and this never reaches the clause it is named for — the mistake a review caught here: a
// bare URL with cleared storage lands on "home" (navigationInit's default), not "feed", so
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
const current = result.current.page;
expect(current).toBe("home");
act(() => result.current.openChannel("UC1", "A channel"));
expect(result.current.channelView).not.toBeNull();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage(current));
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toBeNull();
});
});
describe("NavigationProvider · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history
// entries still name that page, and Back lands on one of them.
it("lands on the feed when the entry names a page this build does not have", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "messages" }, "", "/?page=messages&keep=1");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "messages" } }));
});
expect(result.current.page).toBe("feed");
// …and the entry says so too, so the address bar and a later Feed click agree with the screen.
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.location.search).toBe("?keep=1");
});
it("lands where a retired page's content went, and the entry names that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "downloads" }, "", "/?page=downloads&keep=1");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "downloads" } }));
});
expect(result.current.page).toBe("files");
expect(window.history.state).toMatchObject({ sfPage: "files" });
expect(window.location.search).toBe("?page=files&keep=1");
});
// The audit log and the server log became the activity page's tabs: an old link, bookmark or entry
// opens that tab, with the filters it carried.
it("opens a retired page's tab on the page that replaced it", () => {
window.history.replaceState(null, "", "/?page=audit&keep=1");
const { result } = render();
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&keep=1&at=admin");
});
it("keeps a retired page's own filter as it opens its tab", () => {
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
render();
const url = new URLSearchParams(window.location.search);
expect([url.get("page"), url.get("at"), url.get("lreq")]).toEqual([
"activity",
"logs",
"req-00000009",
]);
});
it("carries an old server-log link's account, text and range onto the page's own filters", () => {
window.history.replaceState(null, "", "/?page=logs&lu=3&lq=boom&lwin=1h");
render();
const url = new URLSearchParams(window.location.search);
expect([url.get("at"), url.get("au"), url.get("aq"), url.get("aw")]).toEqual([
"logs",
"3",
"boom",
"1h",
]);
// …and the dead keys leave the address bar instead of riding along to every page after.
expect(["lu", "lq", "lwin"].filter((k) => url.has(k))).toEqual([]);
});
it("an old server-log link without a range keeps the one it meant", () => {
window.history.replaceState(null, "", "/?page=logs");
render();
expect(new URLSearchParams(window.location.search).get("aw")).toBe("24h");
});
it("a remembered retired page opens its tab", () => {
qc.setQueryData(qk.me(), {
id: 1,
role: "admin",
plex_enabled: true,
preferences: { startOnHome: false },
});
setAccountRaw(PAGE_KEY, "logs");
window.history.replaceState(null, "", "/");
const { result } = render();
expect(result.current.page).toBe("activity");
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
});
// Start-on-Home is the account's preference. An opt-out this browser kept before the server held it
// (the string "0") still counts, and reaches the server for the account's other devices.
it("takes this browser's earlier opt-out of Home, and saves it to the account", () => {
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true, preferences: {} });
setAccountRaw(LS.startOnHome, "0");
setAccountRaw(PAGE_KEY, "plex");
window.history.replaceState(null, "", "/");
const { result } = render();
expect(result.current.page).toBe("plex");
expect(save).toHaveBeenCalledWith({ startOnHome: false });
});
it("opens a retired page's tab when Back lands on its entry", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "audit" }, "", "/?page=audit");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "audit" } }));
});
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&at=admin");
});
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
it("does not put back the focus stamp the landing spent", () => {
render();
const st = { sfPage: "messages", sfNav: { list: 0, lists: 1 } };
act(() => {
window.history.replaceState(st, "", "/?page=messages");
window.dispatchEvent(new PopStateEvent("popstate", { state: st }));
});
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.history.state).not.toHaveProperty("sfNav");
});
});
describe("NavigationProvider · a page the account cannot open", () => {
const member = { id: 1, role: "user", plex_enabled: true };
// A member following a link to an admin page saw the feed with no rail row current: the page
// stayed "scheduler" while only the content fell back.
it("lands on the feed, and the address bar says so", () => {
qc.setQueryData(qk.me(), member);
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
const { result } = render();
expect(result.current.page).toBe("feed");
expect(window.location.search).toBe("?keep=1");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
});
it("lands on the feed when Back reaches an entry naming one", () => {
qc.setQueryData(qk.me(), member);
const { result } = render();
act(() => result.current.setPage("files"));
act(() => {
window.history.replaceState({ sfPage: "config" }, "", "/?page=config");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "config" } }));
});
expect(result.current.page).toBe("feed");
});
it("leaves the page it is on once the account loses it", async () => {
window.history.replaceState(null, "", "/?page=users");
const { result } = render();
expect(result.current.page).toBe("users");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(result.current.page).toBe("feed");
});
// Kept behind the feed, the lost page stayed in the address bar, a click on Feed did nothing (the
// feed was already on screen), and a later grant switched the page unasked.
it("gives the lost page up: the address bar, the remembered page and a later grant agree", async () => {
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
const { result } = render();
expect(result.current.page).toBe("users");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(window.location.search).toBe("");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => {
qc.setQueryData(qk.me(), { ...member, role: "admin" });
});
await settle();
expect(result.current.page).toBe("feed");
});
it("closes a channel page open over the lost page, on screen and in the entry", async () => {
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
expect(window.location.search).toContain("chan=UC1");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(result.current.channelView).toBeNull();
expect(window.location.search).toBe("");
expect(window.history.state?._chan).toBeUndefined();
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
it("closes the channel page in place", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.closeChannel());
expect(back).not.toHaveBeenCalled();
expect(result.current.channelView).toBeNull();
expect(window.location.search).not.toContain("chan=");
});
it("leaves the YouTube search in place", () => {
const { result } = render();
act(() => result.current.enterYtSearch("cats"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).toBeNull();
});
});
describe("NavigationProvider · never under an open overlay", () => {
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
expect(window.history.state).toMatchObject({ sfPage: "plex" });
});
// `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so
// over an open player it must wait for the push, and with nothing to push it still runs.
it("runs setPage's `then` after the push, on the entry the push made", async () => {
const { result } = render();
await openOverlay();
const seen: unknown[] = [];
expect(result.current.page).not.toBe("plex");
act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage)));
expect(seen).toEqual([]);
await settle();
await settle();
expect(seen).toEqual(["plex"]);
act(() => result.current.setPage("plex", () => seen.push("again")));
expect(seen).toEqual(["plex", "again"]);
});
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
it("setPage to the current page closes the overlay and pushes nothing", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage(result.current.page));
await settle();
await settle();
expect(onClose).toHaveBeenCalledTimes(1);
expect(back).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
});
// The player's channel link and the palette's channel rows.
it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.openChannel("UC1", "A channel"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" });
expect(window.history.state).toMatchObject({ _chan: "UC1" });
});
});
@@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
import { pageTitleKey } from "../../lib/pageMeta";
import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import {
backStaysInDocument,
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
rememberNavPosition,
} from "../../lib/history";
import { reachablePage } from "../../lib/modules";
import {
channelViewFrom,
loadInitialPage,
PAGE_KEY,
START_ON_HOME_OPTIONS,
START_ON_HOME_PREF,
type ChannelView,
} from "./navigationInit";
import { usePref } from "../../lib/usePref";
import {
NavigationActionsContext,
NavigationStateContext,
type NavigationActions,
type NavigationState,
} from "./navigationContext";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
// Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside
// ConfirmProvider, around <App/>.
//
// The contexts, their value types and the hooks live in navigationContext.ts.
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
function pageRootParams(page: Page): Record<string, string | null> {
return {
page: page === "feed" ? null : page,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
};
}
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
// this browser held reaches the server at once.
const [startOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
const [chosenPage, setPageState] = useState<Page>(() => loadInitialPage(startOnHome));
// The page on screen: the chosen one when this account may open it, else the feed — derived, so a
// link, a Back and a role taken away mid-session all land the same way, and the rail, the content
// and the address bar (stamped from it below) agree.
const me = useMe();
const page = reachablePage(chosenPage, me);
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
// Back steps player → search → feed instead of the search vanishing without a history entry.
const [ytSearch, setYtSearch] = useState<string | null>(() => {
const st = window.history.state;
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
channelViewFrom(window.history.state, window.location.search),
);
// A page this account can no longer open (a role taken away, a module switched off mid-session)
// is given up, not kept behind the feed: kept, the address bar would go on naming it, a click on
// Feed would be a no-op (setPage's same-page guard sees the feed already on screen), and a later
// grant would switch the page by itself. A channel page open over it closes with it, as on any
// page switch. Adjusted during render; the history entry and the remembered page follow in the
// effect below.
const [givenUp, setGivenUp] = useState(0);
if (page !== chosenPage) {
setPageState(page);
setChannelView(null);
setGivenUp((n) => n + 1);
}
// Latest-value refs so the STABLE action callbacks can read the latest state without depending on
// it — a dep would recreate them on every navigation and re-render every actions consumer (e.g.
// VideoCard). Only READ in event handlers, after commit.
const pageRef = useLatest(page);
const channelViewRef = useLatest(channelView);
const setPage = useCallback(
(next: Page, then?: () => void) =>
// Never under an open overlay — see the interface doc. The refs are read when the push
// actually happens, which over an open player is a moment after this call.
closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) {
then?.();
return;
}
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// root, and the address bar reflects the page (feed is implicit) while clearing the
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
// their page. The item it was opened from is still focused: the state setters above have not
// re-rendered anything yet.
rememberNavPosition();
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams(pageRootParams(next)),
);
notifyNav();
then?.();
}),
[pageRef, channelViewRef],
);
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
// under an open overlay: the player's channel link and the palette's channel rows call it from
// inside one.
const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => {
const url = urlWithParams({ chan: id });
if (window.history.state?._chan) {
const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
} else {
const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
}
setChannelView({ id, name });
});
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
else {
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param.
setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null }));
}
}, []);
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt)
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else {
// New sub-view entry.
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
}
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
else {
setYtSearch(null);
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
window.history.replaceState(st, "");
}
}, []);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
// DURING the render that changes the page, against the page we were last on: as an effect it ran
// after the new page had already committed, so for one frame a module rendered with a live search
// still in the nav state. Keyed on the CHANGE rather than derived from `page`, because "ended" has
// to be permanent — a derived null would quietly come back when you returned to the feed.
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) {
setPageSearchWasOn(page);
if (page !== "feed") setYtSearch(null);
}
// 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 = me != null;
const objectTitle = useDocumentObjectTitle();
useEffect(() => {
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
}, [signedIn, page, objectTitle, i18n.language, t]);
// The page given up above is the feed, and the current entry says so (replaced, not pushed: no
// step was taken). Its own keys go with it, and the channel page closed with it, as on any page
// switch — left in the entry, a reload would reopen what the screen no longer shows.
useEffect(() => {
if (!givenUp) return;
setAccountRaw(PAGE_KEY, "feed");
const { _chan: _closedChan, _chanName: _closedName, ...rest } = window.history.state ?? {};
window.history.replaceState(
{ ...rest, sfPage: "feed" },
"",
urlWithParams(pageRootParams("feed")),
);
}, [givenUp]);
// 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.
const [mountPage] = useState(page);
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
// the popstate handler only needs to read history.state.
useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
// are always kept so a channel page survives F5.
const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read.
// A retired page's link — or the remembered last page, when that is what opened — also opens its
// part of the page that replaced it (an `at` tab).
const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY);
const url = urlWithParams({
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage,
});
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
}
function onPop(e: PopStateEvent) {
// An entry can name a page this build no longer has (one pushed before a module was removed,
// reached by Back after a reload onto the new build); that lands where the page's content went
// (`resolvePage`), else on the feed, not on a crash. The entry is rewritten to say so: left
// alone, the address bar would keep naming the dead page and a click on the page it landed on
// would be a no-op (setPage's same-page guard). It spreads the LIVE entry, not `e.state`:
// lib/history's listener ran first and already took the spent `sfNav` off it.
const sf: unknown = e.state?.sfPage;
const p: Page = (typeof sf === "string" && resolvePage(sf)) || "feed";
if (typeof sf === "string" && sf !== p)
window.history.replaceState(
{ ...window.history.state, sfPage: p },
"",
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }),
);
setPageState(p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
// restores or clears them to match the entry we landed on.
setYtSearch((e.state?._yt as string) ?? null);
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
// shares this resolution with the seed initializer above.
setChannelView(channelViewFrom(e.state, window.location.search));
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, [mountPage]);
const state = useMemo<NavigationState>(
() => ({ page, channelView, ytSearch }),
[page, channelView, ytSearch],
);
const actions = useMemo<NavigationActions>(
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
);
return (
<NavigationActionsContext.Provider value={actions}>
<NavigationStateContext.Provider value={state}>{children}</NavigationStateContext.Provider>
</NavigationActionsContext.Provider>
);
}
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
//
// NavigationProvider's URL-derived seeds, backfilling a code review's deferred coverage:
// • loadInitialPage — the bookmark deep-link precedence (?page > ?v > ?pl > filters/?chan > the
// account's stored landing page > feed).
// • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the
// onPop handler: a popstate landing on a URL-only bookmark entry must restore the
// channel from ?chan, not close it.
// Only getAccountRaw is stubbed (the page the account left open); everything else is real. Whether
// to start on Home is the account's preference, handed in.
import { describe, it, expect, beforeEach, vi } from "vitest";
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
vi.mock("../../lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/storage")>();
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
});
import { loadInitialPage, channelViewFrom } from "./navigationInit";
import { LS } from "../../lib/storage";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
let startOnHome = true;
beforeEach(() => {
setUrl("/");
startOnHome = true;
for (const k of Object.keys(stored)) delete stored[k];
});
describe("loadInitialPage precedence", () => {
it("?page wins and is validated", () => {
setUrl("?page=stats");
expect(loadInitialPage(startOnHome)).toBe("stats");
});
it("an invalid ?page falls back to feed", () => {
setUrl("?page=bogus");
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("?v implies the plex module", () => {
setUrl("?v=show:1");
expect(loadInitialPage(startOnHome)).toBe("plex");
});
it("?pl implies playlists", () => {
setUrl("?pl=3");
expect(loadInitialPage(startOnHome)).toBe("playlists");
});
it.each(["?folder=4", "?item=12", "?folder=all&item=12"])(
"%s implies the Files page (a folder, or a file to arrive at)",
(search) => {
setUrl(search);
expect(loadInitialPage(startOnHome)).toBe("files");
},
);
it("a filter param implies the feed", () => {
setUrl("?tags=1,2");
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("?chan (the channel overlay) implies the feed", () => {
setUrl("?chan=UC1");
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("?page beats a lower-precedence param", () => {
setUrl("?page=stats&v=show:1");
expect(loadInitialPage(startOnHome)).toBe("stats");
});
it("?v beats ?pl and filters", () => {
setUrl("?v=show:1&pl=3&tags=1");
expect(loadInitialPage(startOnHome)).toBe("plex");
});
// A bare URL lands on Home, OVER the remembered module. Landing on
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
// exists to replace, so the remembered page only wins once the account opts out.
it("a bare URL lands on Home even when another module is remembered", () => {
stored[LS.page] = "plex";
expect(loadInitialPage(startOnHome)).toBe("home");
});
it("opting out restores the account's stored landing page", () => {
startOnHome = false;
stored[LS.page] = "plex";
expect(loadInitialPage(startOnHome)).toBe("plex");
});
// The Downloads page is gone; what it held is in Files. A bookmark and a remembered page both land
// there instead of on the feed.
it("a retired page lands where its content went — linked or remembered", () => {
setUrl("?page=downloads");
expect(loadInitialPage(startOnHome)).toBe("files");
setUrl("/");
startOnHome = false;
stored[LS.page] = "downloads";
expect(loadInitialPage(startOnHome)).toBe("files");
});
it("opted out with no / invalid stored page still defaults to feed", () => {
startOnHome = false;
expect(loadInitialPage(startOnHome)).toBe("feed");
stored[LS.page] = "nope";
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("a deep link still beats Home", () => {
setUrl("?pl=3");
expect(loadInitialPage(startOnHome)).toBe("playlists");
});
});
describe("channelViewFrom", () => {
it("reads _chan (+ _chanName) from an in-app entry", () => {
expect(channelViewFrom({ _chan: "UC1", _chanName: "Chan" }, "")).toEqual({
id: "UC1",
name: "Chan",
});
});
it("_chan without a name yields an undefined name", () => {
expect(channelViewFrom({ _chan: "UC1" }, "")).toEqual({ id: "UC1", name: undefined });
});
it("falls back to the ?chan URL param when the entry has no _chan (fresh bookmark)", () => {
expect(channelViewFrom(null, "?chan=UC9")).toEqual({ id: "UC9", name: undefined });
});
it("prefers _chan over the URL param", () => {
expect(channelViewFrom({ _chan: "UCstate" }, "?chan=UCurl")).toEqual({
id: "UCstate",
name: undefined,
});
});
it("returns null when neither is present (channel closed)", () => {
expect(channelViewFrom({}, "")).toBeNull();
expect(channelViewFrom(null, "/")).toBeNull();
});
});
@@ -0,0 +1,56 @@
import { createContext, useContext } from "react";
import type { Page } from "../../lib/urlState";
import type { ChannelView } from "./navigationInit";
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
// components (Fast Refresh can then hot-swap the provider without remounting the tree).
//
// TWO contexts on purpose: a STABLE actions context (setPage/openChannel/… never change reference)
// and a CHANGING state context (page/channelView/ytSearch). Context consumers re-render whenever the
// value changes, so deep memo'd leaves (VideoCard, PlayerModal) subscribe to actions ONLY and never
// re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering
// every existing card.
export interface NavigationState {
page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null;
/** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
ytSearch: string | null;
}
export interface NavigationActions {
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
* after the call; with nothing open it is synchronous. The requested page being already current
* (with no channel page over it) pushes nothing — but the overlays still close.
*
* `then` runs once the navigation has happened (the push made, or found unnecessary) — for a
* request that belongs to the NEW entry, which over an open player does not exist yet at the
* call (`lib/homeIntent`). */
setPage: (next: Page, then?: () => void) => void;
openChannel: (id: string, name?: string) => void;
closeChannel: () => void;
enterYtSearch: (q: string) => void;
exitYtSearch: () => void;
}
export const NavigationStateContext = createContext<NavigationState>({
page: "feed",
channelView: null,
ytSearch: null,
});
export const NavigationActionsContext = createContext<NavigationActions>({
setPage: () => {},
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
});
export function useNavigation(): NavigationState {
return useContext(NavigationStateContext);
}
export function useNavigationActions(): NavigationActions {
return useContext(NavigationActionsContext);
}
@@ -0,0 +1,54 @@
// URL-derived navigation seeds, split out of NavigationProvider so they stay unit-testable AND the
// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing
// module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the
// channelView seed and the onPop handler.
import { hasFilterParams, readPage, resolvePage, type Page } from "../../lib/urlState";
import { getAccountRaw, LS } from "../../lib/storage";
import type { UsePrefOptions } from "../../lib/usePref";
export const PAGE_KEY = LS.page;
/** The landing preference — start on Home, or on the module left open — an account preference
* (usePref). Settings → Appearance → Startup writes it and NavigationProvider reads it for
* `loadInitialPage`, so its key and its reading are defined ONCE. A copy this browser kept before
* the server held it is the string "0" (off) or "1". */
export const START_ON_HOME_PREF = "startOnHome";
export const START_ON_HOME_OPTIONS: UsePrefOptions<boolean> = {
localKey: LS.startOnHome,
parse: (raw) => raw !== false && raw !== "0" && raw !== 0,
};
export interface ChannelView {
id: string;
name?: string;
}
export function loadInitialPage(startOnHome: boolean): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
if (params.has("folder") || params.has("item")) return "files";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → Home, the surface whose whole job is to answer "what should I watch" before
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
// module back.
if (startOnHome) return "home";
return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
}
/** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional
* `_chanName`), else the ?chan URL param — a fresh ?chan bookmark's entry is URL-only. The seed AND
* the popstate handler share this ONE resolver so a popstate that lands on a bookmark entry (no
* `_chan`) restores the channel from the URL instead of wrongly closing it. */
export function channelViewFrom(
state: { _chan?: unknown; _chanName?: unknown } | null | undefined,
search: string,
): ChannelView | null {
const id =
(state?._chan as string | undefined) ?? new URLSearchParams(search).get("chan") ?? undefined;
return id ? { id, name: (state?._chanName as string) ?? undefined } : null;
}
@@ -0,0 +1,272 @@
// @vitest-environment jsdom
//
// The clip panel: it opens on its In handle, every way of marking keeps a range the server takes,
// Cut sends the range (and a Plex clip's audio track) and closes, a refused Cut stays open and says
// so, and "Play selection" loops back to In. The range is the caller's state, so the test holds it.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import type { ClipRequest } from "../../lib/api";
import type { ClipRange } from "../../lib/clipRange";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
});
const notify = vi.fn();
vi.mock("../../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/notifications")>();
return { ...actual, notify: (n: unknown) => notify(n) };
});
const showInTray = vi.fn();
vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) }));
const notifyActionError = vi.fn();
vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
});
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// A block, not an expression: a function returned from beforeEach is run as its teardown.
enqueueClip.mockResolvedValue({ id: 1 });
});
function Harness({
src = { source: "plex", ref: "777", audioTrack: 1 },
start = { start: 100, end: 130 },
current = 100,
onClose = () => {},
onPlayFrom = () => {},
onSeek = () => {},
duration = 3600,
}: {
src?: ClipSource;
start?: ClipRange;
current?: number;
onClose?: () => void;
onPlayFrom?: (t: number) => void;
onSeek?: (t: number, final: boolean) => void;
duration?: number;
}) {
const [clip, setClip] = useState(start);
const [crop, setCrop] = useState<ClipCrop | null>(null);
return (
<QueryClientProvider client={new QueryClient()}>
<output data-testid="range">{`${clip.start}-${clip.end}`}</output>
<output data-testid="crop">{crop ? JSON.stringify(crop) : "none"}</output>
<ClipPanel
src={src}
clip={clip}
onChange={setClip}
duration={duration}
current={current}
picture={16 / 9}
crop={crop}
onCrop={setCrop}
onSeek={onSeek}
onPlayFrom={onPlayFrom}
onClose={onClose}
/>
</QueryClientProvider>
);
}
const range = () => screen.getByTestId("range").textContent;
describe("ClipPanel", () => {
it("opens with the focus on its In handle, so the arrows move the mark at once", () => {
render(<Harness />);
const handle = screen.getByRole("slider", { name: "clip.in" });
expect(document.activeElement).toBe(handle);
fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
expect(range()).toBe("99-130");
});
it("takes a typed time on Enter and carries the other end along past it", () => {
render(<Harness />);
const [inField] = screen.getAllByRole("textbox", { name: "clip.in" });
fireEvent.change(inField!, { target: { value: "2:20" } });
fireEvent.keyDown(inField!, { key: "Enter" });
expect(range()).toBe("140-170");
});
it("keeps the focus in a time field that took a typed time on Enter", () => {
render(<Harness />);
const field = () => screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
field().focus();
fireEvent.change(field(), { target: { value: "2:20" } });
fireEvent.keyDown(field(), { key: "Enter" });
expect(range()).toBe("140-170");
expect(document.activeElement).toBe(field());
expect((field() as HTMLInputElement).value).toBe("0:02:20.0");
});
it("shows the mark it holds, not a time it could not take", () => {
render(<Harness start={{ start: 3500, end: 3600 }} />);
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
// Past the end: clamped back to the end, and the field says so.
fireEvent.change(out(), { target: { value: "1:15:00" } });
fireEvent.blur(out());
expect(range()).toBe("3500-3600");
expect(out().value).toBe("1:00:00.0");
expect(out().getAttribute("aria-invalid")).toBeNull();
// Not a time at all: kept for fixing, and marked as not taken.
fireEvent.change(out(), { target: { value: "abc" } });
fireEvent.blur(out());
expect(out().value).toBe("abc");
expect(out().getAttribute("aria-invalid")).toBe("true");
expect(range()).toBe("3500-3600");
});
it("says an unreadable time on a line of its own under the fields, read out, over nothing", () => {
render(<Harness />);
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
fireEvent.change(out(), { target: { value: "abc" } });
fireEvent.blur(out());
const line = screen.getByRole("alert");
expect(line.textContent).toBe("common.timecodeInvalidHours");
expect(out().getAttribute("aria-describedby")).toBe(line.id);
// In the panel's flow, after the fields' row: it pushes the crop row down rather than lying over
// its buttons and taking their clicks.
expect(line.closest('[role="group"]')).toBeNull();
expect(line.className).not.toMatch(/\babsolute\b/);
expect(line.parentElement?.className).toMatch(/\bmedia-scope\b/);
});
it("takes a decimal comma, as a Hungarian hand types it", () => {
render(<Harness />);
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
fireEvent.change(field, { target: { value: "1:42,5" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(range()).toBe("102.5-130");
});
it("never seeks the player onto the item's very end (that would finish the item)", () => {
const onSeek = vi.fn();
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
expect(range()).toBe("590-600");
const [time, final] = onSeek.mock.lastCall!;
expect(final).toBe(true);
expect(time).toBeLessThan(600);
expect(time).toBeGreaterThan(599);
});
it("marks an end at the playhead", () => {
render(<Harness current={115.5} />);
fireEvent.click(screen.getByRole("button", { name: "clip.outHere" }));
expect(range()).toBe("100-115.5");
});
it("cuts the range with the Plex audio track and closes", async () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} start={{ start: 100.12345, end: 130 }} />);
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
target: { value: " The bit " },
});
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(enqueueClip).toHaveBeenCalledWith({
source: "plex",
ref: "777",
start_s: 100.123,
end_s: 130,
audio_track: 1,
display_name: "The bit",
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }),
);
// Its View opens the tray on the new clip.
(
notify.mock.calls[notify.mock.calls.length - 1]![0] as { action: { onClick: () => void } }
).action.onClick();
expect(showInTray).toHaveBeenCalledWith(1);
});
it("sends no audio track for a YouTube clip", async () => {
render(<Harness src={{ source: "youtube", ref: "abcdefghijk" }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
const body = enqueueClip.mock.calls[0]![0];
expect(body.source).toBe("youtube");
expect(Object.keys(body)).not.toContain("audio_track");
});
it("stays open and says so when the cut is refused", async () => {
const onClose = vi.fn();
const err = new Error("boom");
enqueueClip.mockRejectedValue(err);
render(<Harness onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false,
);
});
it("loops the selection: playing past End goes back to Start", () => {
const onPlayFrom = vi.fn();
const { rerender } = render(<Harness onPlayFrom={onPlayFrom} current={110} />);
fireEvent.click(screen.getByRole("button", { name: "clip.loop" }));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
onPlayFrom.mockClear();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={125} />));
expect(onPlayFrom).not.toHaveBeenCalled();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={130.2} />));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
});
it("cuts the whole picture until the crop is turned on, then the frame with its box's shape", async () => {
render(<Harness />);
expect(screen.queryByRole("group", { name: "clip.shape" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
expect(screen.getByRole("button", { name: "clip.crop" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
expect(enqueueClip.mock.calls[0]![0].crop).toEqual({
x: 0.1,
y: 0.1,
w: 0.8,
h: 0.8,
aspect: 1.7778,
});
});
it("holds the frame to a shape without cutting, and goes back to the whole picture", () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
// A segmented choice inside the panel's form: never a submit.
fireEvent.click(screen.getByRole("button", { name: "9:16" }));
expect(enqueueClip).not.toHaveBeenCalled();
const tall = JSON.parse(screen.getByTestId("crop").textContent!) as ClipCrop;
expect(tall.shape).toBe("9:16");
expect(tall.rect.h).toBeCloseTo(1, 6);
expect((tall.rect.w * 16) / 9 / tall.rect.h).toBeCloseTo(9 / 16, 6);
fireEvent.click(screen.getByRole("button", { name: "clip.whole" }));
expect(JSON.parse(screen.getByTestId("crop").textContent!)).toEqual({
rect: { x: 0, y: 0, w: 1, h: 1 },
shape: "free",
});
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
expect(screen.getByTestId("crop").textContent).toBe("none");
});
});
+468
View File
@@ -0,0 +1,468 @@
import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
import { formatTimecode } from "../../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack";
import { CropFrame } from "../../components/ui/CropFrame";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { Input } from "../../components/ui/Input";
import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField";
import { useTimecodeField, type TimecodeDraft } from "../../components/ui/useTimecodeField";
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
// being watched is the preview: dragging a handle or pressing a timeline moves the player there.
//
// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip
// around the selection where the handles are. The strip's window follows the selection when it
// settles, not while a handle is being dragged — a window that moved under the pointer would run
// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the
// precision, and the fields and their tenth-of-a-second steps give the rest.
//
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
// mark the same range by the same rules (lib/clipRange).
//
// A clip is the whole picture unless the user turns the crop on: then a frame over the video
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
// before it closes the panel.
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
const SHAPES: Record<Exclude<ClipShape, "free">, number> = {
"16:9": 16 / 9,
"9:16": 9 / 16,
"1:1": 1,
};
/** A clip's crop: the frame, in fractions of the picture, and the shape it is held to. */
export interface ClipCrop {
rect: Frac;
shape: ClipShape;
}
const CROP_START: ClipCrop = { rect: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }, shape: "free" };
const round4 = (v: number) => Math.round(v * 10_000) / 10_000;
const lockOf = (crop: ClipCrop, picture: number) =>
crop.shape === "free" ? null : fracRatio(SHAPES[crop.shape], picture);
export interface ClipSource {
source: ClipRequest["source"];
ref: string;
/** Plex: the audio track that is playing, which the clip carries. */
audioTrack?: number | null;
}
interface Props {
src: ClipSource;
clip: ClipRange;
onChange: (r: ClipRange) => void;
duration: number;
/** The player's position, live. */
current: number;
/** The shape (width / height) of the picture the crop frame is drawn on. */
picture: number;
crop: ClipCrop | null;
onCrop: (c: ClipCrop | null) => void;
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
onSeek: (time: number, final: boolean) => void;
/** Play from `time` (the loop's restart, and "Play selection"). */
onPlayFrom: (time: number) => void;
onClose: () => void;
}
const NUDGE_S = 0.1;
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
// the item unfinished.)
const LAST_MOMENT_S = 0.25;
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
// is: the theme accent's ring was under 3:1 on the chrome.
const PLAYHEAD = "bg-media-mark";
export default function ClipPanel({
src,
clip,
onChange,
duration,
current,
picture,
crop,
onCrop,
onSeek,
onPlayFrom,
onClose,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [looping, setLooping] = useState(false);
// The strip's window while a handle drags (null at rest: it follows the selection).
const [frozen, setFrozen] = useState<RangeWindow | null>(null);
const hours = duration >= 3600;
const tc = (s: number) => formatTimecode(s, hours);
const view = frozen ?? zoomWindow(clip, duration);
const bounds = handleBounds(clip, duration);
const length = clip.end - clip.start;
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
// opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without
// scrolling there: the player's card is still fitting itself around the new panel at this point,
// and a card that has to scroll (a short window) scrolled the top of the video out of sight.
useEffect(() => {
rootRef.current
?.querySelector<HTMLElement>('[data-handle="in"]')
?.focus({ preventScroll: true });
}, []);
// The crop turned off from its frame (Escape there): the frame took the focus with it, so it goes to
// the Crop toggle rather than to <body>, where the player's dialog no longer holds it.
const cropButtonRef = useRef<HTMLButtonElement>(null);
const cropOn = crop != null;
useEffect(() => {
if (cropOn) return;
const lost = document.activeElement == null || document.activeElement === document.body;
if (lost) cropButtonRef.current?.focus();
}, [cropOn]);
// "Play selection" loops: past Out, back to In.
const restart = useEffectEvent(() => onPlayFrom(clip.start));
useEffect(() => {
if (looping && current >= clip.end) restart();
}, [looping, current, clip.end]);
const seek = (time: number, final: boolean) =>
onSeek(Math.max(0, Math.min(time, duration - LAST_MOMENT_S)), final);
const moveIn = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
const next = markIn(clip, time, duration);
onChange(next);
seek(next.start, final);
};
const moveOut = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
const next = markOut(clip, time, duration);
onChange(next);
seek(next.end, final);
};
const inField = useTimecodeField(clip.start, hours, (v) => moveIn(v, true));
const outField = useTimecodeField(clip.end, hours, (v) => moveOut(v, true));
const timeErrorId = useId();
const body: ClipRequest = {
source: src.source,
ref: src.ref,
start_s: Math.round(clip.start * 1000) / 1000,
end_s: Math.round(clip.end * 1000) / 1000,
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
...(crop
? {
crop: {
x: round4(crop.rect.x),
y: round4(crop.rect.y),
w: round4(crop.rect.w),
h: round4(crop.rect.h),
aspect: round4(picture),
},
}
: {}),
...(name.trim() ? { display_name: name.trim() } : {}),
};
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (busy) return;
setBusy(true);
try {
const job = await api.enqueueClip(body);
invalidateDownloads(qc);
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({
level: "success",
message: t("clip.queued"),
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed"));
setBusy(false);
}
};
return (
<form
ref={rootRef}
onSubmit={submit}
aria-label={t("clip.title")}
data-testid="clip-panel"
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3">
<span className="flex shrink-0 items-center gap-1.5 font-medium">
<Scissors className="h-4 w-4" aria-hidden="true" />
{t("clip.title")}
</span>
<RangeTrack
label={t("clip.overview")}
duration={duration}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={(time, final) => final && seek(time, true)}
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
regions={[
{
key: "window",
start: view.from,
end: view.to,
className: "border-x border-white/50",
},
{ key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" },
]}
/>
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
<X />
</IconButton>
</div>
<RangeTrack
label={t("clip.strip")}
duration={duration}
view={view}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={seek}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
}
regions={[
{
key: "sel",
start: clip.start,
end: clip.end,
className: "bg-accent/25 border-y-2 border-accent",
},
]}
handles={[
{
id: "in",
label: t("clip.in"),
time: clip.start,
...bounds.in,
valueText: tc(clip.start),
step: NUDGE_S,
bigStep: 1,
onChange: moveIn,
barClassName: "bg-white",
},
{
id: "out",
label: t("clip.out"),
time: clip.end,
...bounds.out,
valueText: tc(clip.end),
step: NUDGE_S,
bigStep: 1,
onChange: moveOut,
barClassName: "bg-white",
},
]}
/>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<MarkField
label={t("clip.in")}
value={clip.start}
field={inField}
errorId={timeErrorId}
onSet={(v) => moveIn(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.inHere")}
onHere={() => moveIn(current, true)}
/>
<MarkField
label={t("clip.out")}
value={clip.end}
field={outField}
errorId={timeErrorId}
onSet={(v) => moveOut(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.outHere")}
onHere={() => moveOut(current, true)}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.length", { length: formatTimecode(length) })}
</span>
<Button
variant="overlay"
size="sm"
pressed={looping}
icon={<Repeat />}
onClick={() => {
if (!looping) onPlayFrom(clip.start);
setLooping(!looping);
}}
>
{t("clip.loop")}
</Button>
</div>
{(inField.invalid || outField.invalid) && <TimecodeError id={timeErrorId} hours={hours} />}
<div className="flex flex-wrap items-center gap-2">
<Button
ref={cropButtonRef}
variant="overlay"
size="sm"
pressed={crop != null}
icon={<Crop />}
onClick={() => onCrop(crop ? null : CROP_START)}
>
{t("clip.crop")}
</Button>
{crop && (
<>
<SegmentedToggle
ariaLabel={t("clip.shape")}
value={crop.shape}
onChange={(shape) => {
const next = { ...crop, shape };
const lock = lockOf(next, picture);
onCrop(lock == null ? next : { ...next, rect: fitRatio(crop.rect, lock) });
}}
options={[
{ value: "free", label: t("clip.shapeFree") },
{ value: "16:9", label: "16:9" },
{ value: "9:16", label: "9:16" },
{ value: "1:1", label: "1:1" },
]}
/>
<Button
variant="overlay"
size="sm"
icon={<Maximize />}
onClick={() => onCrop({ rect: WHOLE, shape: "free" })}
>
{t("clip.whole")}
</Button>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
size="sm"
className="min-w-48 flex-1"
value={name}
maxLength={DISPLAY_NAME_MAX}
onChange={(e) => setName(e.target.value)}
placeholder={t("clip.namePlaceholder")}
aria-label={t("clip.name")}
/>
<Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
{t("clip.cut")}
</Button>
</div>
</form>
);
}
/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward,
* and "to the playhead". */
function MarkField({
label,
value,
field,
errorId,
onSet,
nudge,
hereLabel,
onHere,
}: {
label: string;
value: number;
field: TimecodeDraft;
errorId: string;
onSet: (v: number) => void;
nudge: number;
hereLabel: string;
onHere: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1" role="group" aria-label={label}>
<span className="mr-1 text-muted">{label}</span>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value - nudge)}
label={t("clip.earlier", { label })}
>
<Minus />
</IconButton>
<TimecodeField
size="sm"
className="w-24 text-center tabular-nums"
field={field}
errorId={errorId}
aria-label={label}
/>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value + nudge)}
label={t("clip.later", { label })}
>
<Plus />
</IconButton>
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
<Crosshair />
</IconButton>
</div>
);
}
/** The clip's crop frame, over the player's picture box (the caller positions this layer over it). */
export function ClipCropFrame({
picture,
crop,
onCrop,
}: {
picture: number;
crop: ClipCrop;
onCrop: (c: ClipCrop) => void;
}) {
return (
<CropFrame
aspect={picture}
rect={crop.rect}
ratio={lockOf(crop, picture)}
onChange={(rect) => onCrop({ ...crop, rect })}
className="z-base"
/>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
// @vitest-environment jsdom
//
// A split export that fails part-way. Every clip is its own job,
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify,
}));
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../../lib/api";
import { markReported } from "../../lib/api/core";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
notify.mockReset();
});
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor — a split export that fails part-way", () => {
it("says how many clips made it, and a retry queues only the rest", async () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
let refuse: (e: unknown) => void = () => {};
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValueOnce({} as never)
.mockImplementationOnce(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
)
.mockResolvedValue({} as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
// The playhead is where the video element says it is.
const video = document.querySelector("video")!;
const playhead = (s: number) => {
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
fireEvent.timeUpdate(video);
};
playhead(50);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
// While the clips are queued the cut list holds still: a split now would be a range never queued.
playhead(75);
fireEvent.click(screen.getByText("editor.splitHere"));
// The job cap, as `req` delivers it: already shown in the error dialog, and marked reported.
const refused = new HttpError(422, "errors.generic", { code: "quota" });
markReported(refused);
await act(async () => refuse(refused));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
),
);
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
// The queued first clip is deselected: one clip left to create, and only it is sent again — as
// the second of the same two.
fireEvent.click(screen.getByText("editor.create"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 });
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
});
});
// A time typed into the selected segment's field belongs to that segment: selecting another one
// drops it, and never commits it to the new selection's cut.
describe("VideoEditor — a typed time and another segment", () => {
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
for (const at of [30, 60]) {
Object.defineProperty(video, "currentTime", {
value: at,
configurable: true,
writable: true,
});
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
}
const segs = () => screen.getAllByTitle("editor.keptSeg");
fireEvent.pointerDown(segs()[1]!);
const inField = screen.getByRole("textbox", { name: "editor.in" });
act(() => inField.focus());
fireEvent.change(inField, { target: { value: "0:40" } });
// Another segment is selected before the field loses the focus (a press on it).
fireEvent.pointerDown(segs()[2]!);
fireEvent.blur(inField);
const cuts = screen
.getAllByRole("slider", { name: "editor.cutN" })
.map((c) => c.getAttribute("aria-valuenow"));
expect(cuts).toEqual(["30", "60"]);
});
});
// An unreadable time is said on a line under the segment's row: outside the field's label, so the
// field's name stays "Out" and the line is read once, as its description.
describe("VideoEditor — an unreadable time", () => {
it("is said under the row, outside the field's label", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!);
const out = screen.getByRole("textbox", { name: "editor.out" });
fireEvent.change(out, { target: { value: "abc" } });
fireEvent.keyDown(out, { key: "Enter" });
const line = screen.getByRole("alert");
expect(line.textContent).toBe("common.timecodeInvalid");
expect(line.closest("label")).toBeNull();
expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out);
expect(out.getAttribute("aria-describedby")).toBe(line.id);
});
});
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
// tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => {
it("steps a focused cut with the arrows and stops it before its neighbours", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(cut.getAttribute("aria-valuenow")).toBe("50");
fireEvent.keyDown(cut, { key: "ArrowRight", shiftKey: true });
expect(cut.getAttribute("aria-valuenow")).toBe("51");
fireEvent.keyDown(cut, { key: "End" });
expect(cut.getAttribute("aria-valuenow")).toBe("99.9");
});
});
// A cut's remove button and the keep toggle of the segment after it: the button is a button a click
// or a key works (it sat inside the cut's slider, pointer-only, its arrows moving the cut), and it
// sits at the cut's foot, clear of the toggle at the segment's head; the toggle sits over the cut's
// hit area that reaches into its segment, so a press on it is the toggle's.
describe("VideoEditor — a cut's remove button", () => {
const split = () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
};
it("removes the cut on a click (Enter on a focused button is one), and is not part of the slider", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
});
it("hands the focus on to a cut beside it, or to Split here, never to <body>", async () => {
split();
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1);
act(() => second!.focus());
fireEvent.click(second!);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })),
);
const last = screen.getByRole("button", { name: "editor.removeCut" });
act(() => last.focus());
fireEvent.click(last);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")),
);
});
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
let refuse: (e: unknown) => void = () => {};
vi.spyOn(api, "enqueueEdit").mockImplementation(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
);
split();
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
// The cut list holds still while the clips are queued: the remove is refused.
const remove = screen.getByRole("button", { name: "editor.removeCut" });
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
await act(async () => refuse(new Error("refused")));
// The create failed and the editor stays; a later split keeps the focus where it was.
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
act(() => splitButton.focus());
fireEvent.click(splitButton);
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
expect(document.activeElement).toBe(splitButton);
});
it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
const track = screen.getByRole("group", { name: "editor.timeline" });
// A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track.
expect(track.contains(remove)).toBe(false);
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(remove.closest<HTMLElement>("[style]")?.style.left).toBe(cut.style.left);
const toggles = within(track).getAllByRole("button", { pressed: true });
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
});
});
@@ -0,0 +1,638 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { invalidateDownloads } from "../../lib/downloads";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
import clsx from "clsx";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame";
import { Button } from "../../components/ui/Button";
import { failureReason, notifyActionError } from "../../lib/actionErrors";
import { markReported } from "../../lib/api/core";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField";
import { useTimecodeField } from "../../components/ui/useTimecodeField";
import { SURFACE } from "../../components/ui/surfaces";
import { RangeTrack } from "../../components/ui/RangeTrack";
import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode";
type Seg = { id: number; start: number; end: number; keep: boolean };
const MIN_SEG = 0.1;
/** Queues each clip as its own job, in order; `done` collects the ids that made it, so the caller's
* catch can say how many did when one fails part-way. */
async function enqueueClips(
jobId: number,
clips: { id: number; spec: EditSpec; dn: string | undefined }[],
done: number[],
) {
for (const c of clips) {
await api.enqueueEdit({ source_job_id: jobId, edit_spec: c.spec, display_name: c.dn });
done.push(c.id);
}
}
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const splitRef = useRef<HTMLButtonElement>(null);
const sid = useRef(1);
const nid = () => sid.current++;
const srcDur = job.asset?.duration_s ?? 0;
const [duration, setDuration] = useState(srcDur);
const [current, setCurrent] = useState(0);
const [playing, setPlaying] = useState(false);
const [trackW, setTrackW] = useState(0);
// Intrinsic (source) video dims: the crop frame's picture shape, and the crop's source pixels.
// From the asset's metadata, so the frame sizes before the media decodes.
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
// Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids
// Create started with, so the cut list holds still until it is done: a split or a merge in between
// would deselect a range that was never queued, or keep one that was.
const creating = useRef(false);
// The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way
// failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)").
const series = useRef<Map<number, string> | null>(null);
const [selId, setSelId] = useState(0);
const [output, setOutput] = useState<"separate" | "join">("separate");
const [cropOn, setCropOn] = useState(false);
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
const [accurate, setAccurate] = useState(true);
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
const aspect =
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
const sb = useQuery({
queryKey: qk.storyboard(job.id),
queryFn: () => api.downloadStoryboard(job.id),
staleTime: Infinity,
retry: false,
});
const strip = sb.data?.available ? sb.data : null;
const defaultName = job.display_name || job.asset?.title || "";
// Measure the track for aspect-correct filmstrip tiling.
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setTrackW(el.clientWidth));
ro.observe(el);
setTrackW(el.clientWidth);
return () => ro.disconnect();
}, []);
// The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's
// box itself: max-h can pillarbox it).
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
const onLoaded = () => {
const v = videoRef.current;
if (!v) return;
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
setDuration(d);
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
// the no-op guard (isFullSingle: end >= duration) reads false and enables a "Create" that files a
// clip of an unmodified video and drops its tail. Only touches the untouched segment (end===srcDur).
setSegments((s) => (s.length === 1 && s[0]!.end === srcDur ? [{ ...s[0]!, end: d }] : s));
};
const onTimeUpdate = () => {
const v = videoRef.current;
if (v) setCurrent(v.currentTime);
};
const seek = useCallback(
(s: number) => {
const v = videoRef.current;
if (v) v.currentTime = Math.max(0, Math.min(duration, s));
},
[duration],
);
const playPause = () => {
const v = videoRef.current;
if (!v) return;
// play() rejects when a pause or a source change interrupts it; the button state follows the
// element's own events, so there is nothing to undo.
if (v.paused) v.play().catch(() => {});
else v.pause();
};
// --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
// A cut removed by its button takes the button with it: once it is gone, the focus moves on to the
// cut before it (or the one now first), else to Split here — not to <body>, out of the dialog.
const refocusAfterRemove = useRef<number | null>(null);
useEffect(() => {
const bi = refocusAfterRemove.current;
if (bi == null) return;
refocusAfterRemove.current = null;
const cut = trackRef.current?.querySelector<HTMLElement>(
`[data-handle="cut-${Math.max(0, bi - 1)}"]`,
);
(cut ?? splitRef.current)?.focus();
}, [boundaries.length]);
const splitAtPlayhead = () => {
if (creating.current) return;
const time = current;
setSegments((segs) => {
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
if (i < 0) return segs;
const s = segs[i]!; // i >= 0 from findIndex, so segs[i] exists
const b: Seg = { id: nid(), start: time, end: s.end, keep: s.keep };
const next = [...segs.slice(0, i), { ...s, end: time }, b, ...segs.slice(i + 1)];
return next;
});
};
const moveBoundary = useCallback(
(bi: number, time: number) =>
setSegments((segs) => {
if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
const lo = segs[bi]!.start + MIN_SEG;
const hi = segs[bi + 1]!.end - MIN_SEG;
const tt = Math.max(lo, Math.min(hi, time));
const copy = [...segs];
copy[bi] = { ...copy[bi]!, end: tt };
copy[bi + 1] = { ...copy[bi + 1]!, start: tt };
return copy;
}),
[],
);
const deleteBoundary = (bi: number) =>
setSegments((segs) => {
if (creating.current || segs.length < 2) return segs;
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
});
const toggleKeep = (id: number) =>
setSegments((segs) =>
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
);
// --- timeline hover preview ---
// The hover point, measured in the track's pointer handler (track-relative x and its time), so
// render never reads the track's geometry (a DOM read in render made the React Compiler skip it).
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
// --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
const tileStyle = (idx: number): React.CSSProperties => {
const cols = strip?.cols ?? 12;
const rows = strip?.rows ?? 1;
const i = Math.max(0, Math.min((strip?.count ?? 1) - 1, idx));
const col = i % cols;
const row = Math.floor(i / cols);
return {
backgroundImage: `url(${api.storyboardImageUrl(job.id)})`,
backgroundSize: `${cols * 100}% ${rows * 100}%`,
backgroundPosition: `${cols > 1 ? (col / (cols - 1)) * 100 : 0}% ${rows > 1 ? (row / (rows - 1)) * 100 : 0}%`,
};
};
const stripCells = strip && trackW ? Math.max(1, Math.floor(trackW / (44 * aspect))) : 0;
const hoverTime = hover?.time ?? null;
// Clamp the hover-scrub popover inside the track so it never overflows the modal (which would
// add a horizontal scrollbar): center it on the cursor, but keep both edges within [0, trackW].
const HOVER_W = 148;
const hoverLeft =
hover != null
? Math.max(0, Math.min(Math.max(0, (trackW || HOVER_W) - HOVER_W), hover.x - HOVER_W / 2))
: 0;
// --- create ---
const kept = segments.filter((s) => s.keep);
const keptDur = kept.reduce((a, s) => a + (s.end - s.start), 0);
const isFullSingle =
segments.length === 1 &&
kept.length === 1 &&
kept[0]!.start <= 0.01 &&
kept[0]!.end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const v = videoRef.current;
const vw = v?.videoWidth || vdim.w;
const vh = v?.videoHeight || vdim.h;
if (!cropOn) return undefined;
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
return cropToSourcePx(crop, vw, vh) ?? undefined;
};
const create = useMutation({
mutationFn: async () => {
creating.current = true;
const cp = cropPx();
const base = name.trim() || defaultName;
if (willJoin) {
const spec: EditSpec = {
segments: kept.map((s) => ({ start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) })),
accurate,
};
if (cp) spec.crop = cp;
await api.enqueueEdit({
source_job_id: job.id,
edit_spec: spec,
display_name: name.trim() || undefined,
});
return { files: 1 };
}
const N = kept.length;
const prev = series.current;
const suffix =
prev && kept.every((s) => prev.has(s.id))
? prev
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
series.current = suffix;
// Every clip's request is built before the try, and queued by a helper: the React Compiler
// cannot lower a conditional or a loop inside one yet, and skipped the whole editor for it.
const clips = kept.map((s) => {
const spec: EditSpec = {
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
return { id: s.id, spec, dn };
});
const done: number[] = [];
try {
await enqueueClips(job.id, clips, done);
} catch (e) {
// Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left
// clips 1 and 2 queued while the editor reported a plain failure and kept all three selected,
// so trying again queued the first two a second time. Say how many made it, and deselect them
// so another try adds only the rest.
if (done.length) {
invalidateDownloads(qc);
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is
// what the dialog does not tell.
notify({
level: "error",
title: t("editor.partlyCreated", { done: done.length, total: N }),
message: failureReason(e),
});
markReported(e);
}
throw e;
}
return { files: N };
},
onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc);
notify({ level: "success", message: t("editor.created", { count: files }) });
onClose();
},
onError: (e) => notifyActionError(e, t("editor.failed")),
onSettled: () => {
creating.current = false;
},
});
const sel = segments.find((s) => s.id === selId) ?? segments[0];
const selIdx = segments.findIndex((s) => s.id === sel?.id);
const hours = duration >= 3600;
const inField = useTimecodeField(
sel?.start ?? 0,
hours,
(v) => selIdx > 0 && moveBoundary(selIdx - 1, v),
);
const outField = useTimecodeField(
sel?.end ?? 0,
hours,
(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v),
);
const timeErrorId = useId();
return (
<Modal
initialFocus="field"
title={t("editor.title", { name: defaultName })}
onClose={onClose}
maxWidth="max-w-3xl"
>
<div className="space-y-3">
{/* video + crop overlay */}
<div className="relative bg-black rounded-lg overflow-hidden select-none">
<video
ref={videoRef}
src={fileUrl}
onLoadedMetadata={onLoaded}
onTimeUpdate={onTimeUpdate}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="w-full max-h-[42dvh] mx-auto block object-contain"
playsInline
/>
{cropOn && <CropFrame aspect={va} rect={crop} ratio={null} onChange={setCrop} />}
</div>
{/* transport */}
<div className="flex items-center gap-3">
<IconButton variant="outline" size="md" onClick={playPause} label={t("editor.playPause")}>
{playing ? <Pause /> : <Play />}
</IconButton>
<div className="text-xs text-muted tabular-nums">
{tc(current)} / {tc(duration)}
</div>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
ref={splitRef}
onClick={splitAtPlayhead}
icon={<SplitSquareHorizontal />}
>
{t("editor.splitHere")}
</Button>
</div>
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
<div className="relative">
<div ref={trackRef}>
<RangeTrack
label={t("editor.timeline")}
duration={duration}
playhead={current}
onSeek={(time) => {
setCurrent(time);
seek(time);
}}
onHover={setHover}
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
background={
stripCells > 0 && (
<div className="absolute inset-0 flex pointer-events-none">
{Array.from({ length: stripCells }).map((_, i) => (
<div
key={i}
className="h-full flex-1"
style={tileStyle(
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
)}
/>
))}
</div>
)
}
regions={segments.map((s) => ({
key: s.id,
start: s.start,
end: s.end,
// segment tint: dropped = dimmed + hatched
className: clsx(
s.end < duration && "border-r border-black/40",
s.keep ? "bg-accent/10" : "bg-black/60",
s.id === selId && "ring-2 ring-inset ring-white/70",
),
style: s.keep
? undefined
: {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
},
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
onPointerDown: () => setSelId(s.id),
children: (
// keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the
// segment): a press on the eye is the eye's, never a drag of the cut
<Pressable
aria-pressed={s.keep}
onPointerDown={(e) => {
e.stopPropagation();
toggleKeep(s.id);
}}
className={clsx(
"absolute top-1 left-1 z-menu p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
s.keep ? "text-accent" : "text-muted",
)}
title={s.keep ? t("editor.drop") : t("editor.keep")}
>
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</Pressable>
),
}))}
// The cuts between segments: dragged, or stepped with the arrows once focused.
handles={boundaries.map((b, bi) => ({
id: `cut-${bi}`,
label: t("editor.cutN", { n: bi + 1 }),
time: b,
min: segments[bi]!.start + MIN_SEG,
max: segments[bi + 1]!.end - MIN_SEG,
valueText: tc(b),
step: 0.1,
bigStep: 1,
onChange: (time: number) => moveBoundary(bi, time),
}))}
/>
</div>
{/* Each cut's remove button, under the track at the cut: a real button a click or Enter
works, off the cut's grab area, where a press drags it (a removal has no undo). */}
{boundaries.length > 0 && (
<div className="relative mt-1 h-6">
{boundaries.map((b, bi) => (
<div
key={`cut-${bi}`}
className="absolute top-0 -translate-x-1/2"
style={{ left: trackLeft(b, { from: 0, to: duration }) }}
>
<Pressable
onClick={() => {
// Refused while the clips are queued (the list holds still): then nothing is
// removed, and no focus is left waiting to be taken by a later change.
if (creating.current) return;
refocusAfterRemove.current = bi;
deleteBoundary(bi);
}}
// 24px, the target floor; white on its black disc in either theme.
className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white"
aria-label={t("editor.removeCut", { n: bi + 1 })}
title={t("editor.removeCut", { n: bi + 1 })}
>
<X className="w-3 h-3" />
</Pressable>
</div>
))}
</div>
)}
{/* hover-scrub thumbnail */}
{strip && hoverTime != null && (
<div
className="absolute bottom-full mb-2 pointer-events-none z-chrome rounded-md overflow-hidden border border-border shadow-xl bg-black"
style={{ left: hoverLeft, width: HOVER_W }}
>
<div
style={{
width: HOVER_W,
height: HOVER_W / aspect,
...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))),
}}
/>
<div className="text-3xs text-center text-muted py-0.5 tabular-nums">
{tc(hoverTime)}
</div>
</div>
)}
</div>
{/* selected-segment editor */}
{sel && (
<div className={`${SURFACE.card} flex flex-wrap items-center gap-2 text-sm px-3 py-2`}>
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
<Button
variant="outline"
size="xs"
pressed={sel.keep}
onClick={() => toggleKeep(sel.id)}
icon={sel.keep ? <Eye /> : <EyeOff />}
>
{sel.keep ? t("editor.kept") : t("editor.dropped")}
</Button>
<div className="flex-1" />
<label className="flex items-center gap-1 text-muted">
{t("editor.in")}
<TimecodeField
className="w-24 tabular-nums"
size="sm"
field={inField}
errorId={timeErrorId}
disabled={selIdx === 0}
/>
</label>
<label className="flex items-center gap-1 text-muted">
{t("editor.out")}
<TimecodeField
className="w-24 tabular-nums"
size="sm"
field={outField}
errorId={timeErrorId}
disabled={selIdx === segments.length - 1}
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">
{tc(sel.end - sel.start)}
</span>
{(inField.invalid || outField.invalid) && (
<div className="basis-full">
<TimecodeError id={timeErrorId} hours={hours} />
</div>
)}
</div>
)}
{/* output mode + summary */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => (
<Button
key={m}
variant="outline"
size="sm"
pressed={output === m}
onClick={() => setOutput(m)}
disabled={m === "join" && kept.length < 2}
>
{t(`editor.output.${m}`)}
</Button>
))}
<div className="flex-1" />
<span className="text-xs text-muted">
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
</span>
</div>
{/* crop + precision */}
<div className="grid sm:grid-cols-2 gap-3">
<Button
variant="outline"
pressed={cropOn}
onClick={() => setCropOn((v) => !v)}
icon={<Crop />}
className="justify-start"
>
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</Button>
{!cropOn ? (
<div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate;
return (
<Button
key={m}
variant="outline"
pressed={on}
onClick={() => setAccurate(m === "accurate")}
title={t(`editor.${m}.hint`)}
className="flex-1"
>
{t(`editor.${m}.label`)}
</Button>
);
})}
</div>
) : (
<div className="flex items-center px-3 text-xs text-muted">
{t("editor.cropReencode")}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("editor.nameLabel")}</label>
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultName}
/>
</div>
<ModalFooter className="flex items-center justify-between gap-2">
<span className="text-xs text-muted">
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => create.mutate()}
disabled={!valid}
loading={create.isPending}
icon={<Scissors className="w-4 h-4" />}
>
{willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
//
// A link at the end of a sentence lost the punctuation after it — the trailing
// `.,;:!?)]` was trimmed off the href and then dropped from the text too — and a URL whose own path
// ends in a balanced ")" (a Wikipedia article) was cut short and broken.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import type { TFunction } from "i18next";
import { renderDescription } from "./descriptionLinks";
afterEach(cleanup);
function shown(text: string) {
const { container } = render(
<div>
{renderDescription(text, {
currentId: "x",
onSeek: vi.fn(),
onLoadVideo: vi.fn(),
t: ((k: string) => k) as unknown as TFunction,
})}
</div>,
);
return {
text: container.textContent,
hrefs: [...container.querySelectorAll("a")].map((a) => a.getAttribute("href")),
};
}
describe("a link in a description", () => {
it("keeps the punctuation after it in the text, out of the link", () => {
const r = shown("See https://example.com/page. And (https://example.org/x), too!");
expect(r.text).toBe("See https://example.com/page. And (https://example.org/x), too!");
expect(r.hrefs).toEqual(["https://example.com/page", "https://example.org/x"]);
});
it("keeps a balanced parenthesis that belongs to the address", () => {
const r = shown("Read https://en.wikipedia.org/wiki/Mercury_(planet).");
expect(r.hrefs).toEqual(["https://en.wikipedia.org/wiki/Mercury_(planet)"]);
expect(r.text).toBe("Read https://en.wikipedia.org/wiki/Mercury_(planet).");
});
});
@@ -0,0 +1,172 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import { Pressable } from "../../components/ui/Pressable";
// Turn a video description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
// - a YouTube link to the *current* video → seek (to its t= if any)
// - a YouTube link to *another* video → load it in the inline player
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
// Pure logic (no React state), factored out of PlayerModal so it's testable on its own.
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
const INLINE_RE =
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
function tsToSeconds(ts: string): number {
const parts = ts.split(":").map((n) => parseInt(n, 10));
const [a = 0, b = 0, c = 0] = parts; // hh:mm:ss or mm:ss — absent segments default to 0
return parts.length === 3 ? a * 3600 + b * 60 + c : a * 60 + b;
}
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
function parseStart(t: string | null): number | null {
if (!t) return null;
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!m) return null;
const total =
parseInt(m[1] || "0", 10) * 3600 + parseInt(m[2] || "0", 10) * 60 + parseInt(m[3] || "0", 10);
return total || null;
}
// Extract a video id (+ optional start) from a YouTube URL, else null.
function parseYouTube(url: string): { id: string; start: number | null } | null {
let u: URL;
try {
u = new URL(url);
} catch {
return null;
}
const host = u.hostname.replace(/^www\./, "");
let id: string | null = null;
if (host === "youtu.be") {
id = u.pathname.slice(1) || null;
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
if (u.pathname === "/watch") id = u.searchParams.get("v");
else {
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
if (m) id = m[1] ?? null;
}
}
if (!id) return null;
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
}
/** The address inside a matched URL: sentence punctuation after it is not part of it, but a ")" that
* closes a "(" of its own is (a Wikipedia article's "Mercury_(planet)"). */
function trimUrl(raw: string): string {
let end = raw.length;
for (;;) {
const c = raw[end - 1];
if (c && ".,;:!?]".includes(c)) end--;
else if (c === ")") {
const body = raw.slice(0, end);
if (body.split(")").length > body.split("(").length) end--;
else break;
} else break;
}
return raw.slice(0, end);
}
export function renderDescription(
text: string,
opts: {
currentId: string;
onSeek: (seconds: number) => void;
onLoadVideo: (id: string, start: number | null) => void;
t: TFunction;
},
): ReactNode[] {
const { t } = opts;
const out: ReactNode[] = [];
let key = 0;
const linkCls = "text-accent hover:underline break-all";
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
// (they're just noise in the popover), keeping single line breaks.
const clean = text
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{2,}/g, "\n")
.trim();
for (const chunk of clean.split(URL_RE)) {
if (/^https?:\/\//.test(chunk)) {
const href = trimUrl(chunk);
// What was trimmed is the sentence's, not the link's: it stays in the text, after the link.
const tail = chunk.slice(href.length);
const yt = parseYouTube(href);
if (yt) {
const sameVideo = yt.id === opts.currentId;
out.push(
<Pressable
selectable
key={key++}
onClick={() =>
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
}
className={linkCls}
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
>
{href}
</Pressable>,
);
} else {
out.push(
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
{href}
</a>,
);
}
if (tail) out.push(<span key={key++}>{tail}</span>);
continue;
}
// Plain text: pull out emails, hashtags and bare timestamps.
INLINE_RE.lastIndex = 0;
let last = 0;
let m: RegExpExecArray | null;
while ((m = INLINE_RE.exec(chunk))) {
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
if (m[1]) {
// Email — trim trailing punctuation the domain rule may have swallowed.
const email = m[1].replace(/[.,;:]+$/, "");
out.push(
<a key={key++} href={`mailto:${email}`} className={linkCls}>
{email}
</a>,
);
if (m[1].length > email.length)
out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
} else if (m[2]) {
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
const tag = m[2].slice(1);
out.push(
<a
key={key++}
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
target="_blank"
rel="noreferrer"
className={linkCls}
>
{m[2]}
</a>,
);
} else {
// Reached only when neither m[1] (email) nor m[2] (hashtag) matched, so the third
// alternative — the timestamp group — is the one that fired.
const ts = m[3]!;
out.push(
<Pressable
selectable
key={key++}
onClick={() => opts.onSeek(tsToSeconds(ts))}
className="text-accent hover:underline"
>
{ts}
</Pressable>,
);
}
last = m.index + m[0].length;
}
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
}
return out;
}
@@ -0,0 +1,161 @@
import type { CSSProperties, Ref } from "react";
import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { VideoDetail } from "../../lib/api";
import type { AutoMode, LoopMode } from "../../lib/playerModes";
import { Button } from "../../components/ui/Button";
import { StateMessage } from "../../components/ui/QueryState";
import { ClampedText } from "../../components/ui/ClampedText";
import { renderDescription } from "./descriptionLinks";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
export function PlayerTransportControls({
variant,
index,
queueLength,
autoMode,
loopMode,
onPrev,
onNext,
onCycleAuto,
onCycleLoop,
}: {
variant: "docked" | "modal";
index: number;
queueLength: number;
autoMode: AutoMode;
loopMode: LoopMode;
onPrev: () => void;
onNext: () => void;
onCycleAuto: () => void;
onCycleLoop: () => void;
}) {
const { t } = useTranslation();
const docked = variant === "docked";
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
const chrome = docked ? "overlay" : "ghost";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<Button
variant={chrome}
size="sm"
icon={<SkipBack />}
onClick={onPrev}
disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
>
<span className={navLabel}>{t("player.previous")}</span>
</Button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<Button
variant={chrome}
size="sm"
onClick={onNext}
disabled={index === queueLength - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward />
</Button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<Button
variant={chrome}
size="sm"
pressed={autoMode !== "off"}
icon={
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
}
onClick={onCycleAuto}
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
title={t("player.autoAdvance.hint")}
>
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</Button>
<Button
variant={chrome}
size="sm"
pressed={loopMode !== "off"}
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
onClick={onCycleLoop}
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
title={t("player.loop.hint")}
>
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</Button>
</>
);
}
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
* ladder, so the two cannot drift (the failed state was once tested in the panel only).
* `ref` / `style` / `className` dress the text's box only. `clamp` cuts it to three lines with a
* More / Less (the panel, where the description sits under the actions and is rarely read whole). */
export function PlayerDescription({
detail,
currentId,
onSeek,
onLoadVideo,
className,
style,
ref,
clamp = false,
}: {
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
currentId: string;
onSeek: (seconds: number) => void;
onLoadVideo: (id: string, start: number | null) => void;
className: string;
style?: CSSProperties;
ref?: Ref<HTMLDivElement>;
clamp?: boolean;
}) {
const { t } = useTranslation();
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
if (detail.isError && !detail.data)
return (
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
{t("player.descriptionFailed")}
</StateMessage>
);
const text = detail.data?.description;
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
if (clamp)
return (
<ClampedText
text={text}
lines={3}
moreLabel={t("player.descriptionMore")}
lessLabel={t("player.descriptionLess")}
textClassName={className}
>
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
</ClampedText>
);
return (
<div ref={ref} style={style} className={className}>
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
</div>
);
}
@@ -0,0 +1,139 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { api, HttpError, type PlexCollection } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in
// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new
// collection, its name, a search for the title, then another search to add the next one. Its body is
// the same MembershipMenu as the video card's "Add to playlist".
export interface PlexCollectionTarget {
ratingKey: string;
anchor: Element;
}
export default function PlexCollectionAddAt({
target,
onClose,
}: {
target: PlexCollectionTarget;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const pop = useAnchoredPopover({
open: true,
onOpenChange: (next) => {
if (!next) onClose();
},
offset: 6,
hostsInput: true,
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]);
const [busy, setBusy] = useState(false);
const { ratingKey } = target;
const forKey = qk.plexCollections("for", ratingKey);
const list = useQuery({
queryKey: forKey,
queryFn: () => api.plexCollectionsFor(ratingKey),
});
// The title's own page (a movie's item, a show's show), the collections' pages and the library grid
// all show membership.
function refresh() {
void qc.invalidateQueries({ queryKey: qk.plexCollections() });
void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexShow(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// A write that landed flips the tick at once: the row's state comes from the list, and a second tap
// before its refetch returned would otherwise repeat the write instead of undoing it.
async function toggle(c: PlexCollection & { contains: boolean }) {
if (busy) return;
setBusy(true);
try {
if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey);
else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
}
async function create(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
// No library: the server takes the seed title's own.
await api.plexCreateCollection("", name, ratingKey);
made = true;
} catch (e) {
made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
return made;
}
const collections = list.data?.collections ?? [];
return (
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-64 p-2`}
ariaLabel={t("plex.collectionAdd.title")}
listInside
>
<MembershipMenu
heading={t("plex.collectionAdd.title")}
rows={collections.map((c) => ({ id: c.id, label: c.title, checked: c.contains }))}
onToggle={(row) => {
const c = collections.find((x) => x.id === row.id);
if (c) void toggle(c);
}}
status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"}
onRetry={() => void list.refetch()}
emptyText={t("plex.collectionAdd.none")}
errorText={t("plex.collectionAdd.loadFailed")}
busy={busy}
create={{
placeholder: t("plex.collection.newCollection"),
ariaLabel: t("plex.collection.namePlaceholder"),
onCreate: create,
}}
/>
</PopoverSurface>
);
}
/** A create refused only because the read after it failed: the collection is on Plex, with this
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
function madeOnPlex(e: unknown): boolean {
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
return d?.reason === "collection_pending_sync";
}
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
old: T | undefined,
id: string,
): T | undefined {
if (!old) return old;
return {
...old,
collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)),
};
}
@@ -0,0 +1,182 @@
import { useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { MenuCheck } from "../../components/ui/MenuCheck";
import { api, type PlexPlaylist } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
export type PlexAddTarget =
| { kind: "single"; ratingKey: string; title: string }
| { kind: "group"; ratingKeys: string[]; title: string };
// "Add to playlist" dialog — per-user (every user has their own playlists). Opened from the movie info
// page (single) and the show page (single episode / whole season / whole show). Lists my playlists with
// an in/out (or partial) state and a field to create a new one seeded with the target.
export default function PlexPlaylistAdd({
target,
onClose,
}: {
target: PlexAddTarget;
onClose: () => void;
}) {
const { t } = useTranslation();
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
// Optimistic per-playlist in-count override (0..size), so a toggle reflects instantly.
const [override, setOverride] = useState<Map<number, number>>(new Map());
const isGroup = target.kind === "group";
const size = isGroup ? new Set(target.ratingKeys).size : 1;
const listQ = useQuery({
queryKey: isGroup
? qk.plexPlaylists("group", target.ratingKeys)
: qk.plexPlaylists("contains", target.ratingKey),
queryFn: () =>
isGroup
? api.plexPlaylists(undefined, target.ratingKeys)
: api.plexPlaylists(target.ratingKey),
});
const playlists = listQ.data?.playlists ?? [];
const refresh = () => void qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
// How many of the target a playlist currently holds (0..size).
function inCount(p: PlexPlaylist): number {
const o = override.get(p.id);
if (o !== undefined) return o;
return isGroup ? (p.group_in ?? 0) : p.has_item ? 1 : 0;
}
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
// another device (a 404 `not_found/playlist` — still offered here, from a cache up to 30 s old)
// was an unhandled rejection: `req` leaves 403/404/429 to the caller, so nothing was shown, the
// tick never flipped and the gone row stayed. A sweep fixed the collection editor and its own
// YouTube twin (`AddToPlaylist`) and missed this one.
async function toggle(p: PlexPlaylist) {
if (busy) return;
setBusy(true);
const allIn = inCount(p) >= size;
try {
if (isGroup) {
if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys);
else await api.plexPlaylistAddBulk(p.id, target.ratingKeys);
} else {
if (allIn) await api.plexPlaylistRemoveItem(p.id, target.ratingKey);
else await api.plexPlaylistAddItem(p.id, target.ratingKey);
}
setOverride((m) => new Map(m).set(p.id, allIn ? 0 : size));
} catch (e) {
notifyActionError(
e,
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
);
}
refresh();
setBusy(false);
}
async function createNew() {
const title = newName.trim();
if (!title || busy) return;
setBusy(true);
try {
if (isGroup) {
const pl = await api.plexCreatePlaylist(title);
await api.plexPlaylistAddBulk(pl.id, target.ratingKeys);
setOverride((m) => new Map(m).set(pl.id, size));
} else {
const pl = await api.plexCreatePlaylist(title, target.ratingKey);
setOverride((m) => new Map(m).set(pl.id, 1));
}
setNewName("");
} catch (e) {
// The create may already have landed when the add fails — `refresh()` in `finally` is what
// makes that half-done state visible instead of leaving a playlist the dialog never lists.
notifyActionError(e, t("plex.playlistAdd.createFailed"));
}
refresh();
setBusy(false);
}
return (
<Modal
initialFocus="field"
title={t("plex.playlistAdd.title", { title: target.title })}
onClose={onClose}
>
<div className="mb-3 flex gap-2">
<Input
className="flex-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.playlistAdd.newPlaceholder")}
/>
<Button
variant="glass"
size="md"
className="shrink-0"
onClick={createNew}
disabled={!newName.trim() || busy}
>
<Plus className="h-4 w-4" />
{t("plex.playlistAdd.create")}
</Button>
</div>
<div
ref={fadeRef}
style={fadeStyle}
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
>
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : playlists.length === 0 ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.playlistAdd.none")}</p>
) : (
playlists.map((p) => {
const n = inCount(p);
const all = n >= size;
const partial = n > 0 && n < size;
return (
<Pressable
key={p.id}
role="checkbox"
aria-checked={all ? true : partial ? "mixed" : false}
onClick={() => toggle(p)}
disabled={busy}
className={`${SURFACE.card} flex w-full items-center gap-2 px-2 py-1.5 text-sm disabled:opacity-50 ${
all ? "text-accent" : ""
}`}
>
<MenuCheck kind="many" on={all ? true : partial ? "mixed" : false} />
<span className="min-w-0 flex-1 truncate">{p.title}</span>
{isGroup ? (
<span className="shrink-0 text-xs text-muted tabular-nums">
{t("plex.playlistAdd.groupProgress", { in: n, size })}
</span>
) : (
<span className="shrink-0 text-xs text-muted">
{t("plex.playlistAdd.count", { count: p.item_count })}
</span>
)}
</Pressable>
);
})
)}
</div>
</Modal>
);
}
@@ -0,0 +1,522 @@
// @vitest-environment jsdom
//
// Plex card a11y: locks the accessible names on the icon-only card controls (an a11y sweep). These
// buttons are icon-only (WatchedIcon / ListPlus / the "…", no text), so their name comes solely from the
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
// key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render as rtlRender, screen, cleanup, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
// The key, and the episode a "next" line names after a colon (`{ name }`).
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key),
}),
};
});
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import { iconButtonClass } from "../../components/ui/buttonStyles";
import { api, type PlexCard, type PlexSeasonDetail } from "../../lib/api";
// A show card asks the server which episode it would play once its preview is up: a query client.
const render = (ui: ReactElement) =>
rtlRender(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
),
});
const movie = (status?: string): PlexCard => ({
id: "1",
type: "movie",
title: "M",
thumb: "",
status,
});
const episode = (status?: string): PlexCard => ({
id: "e1",
type: "episode",
title: "E1",
thumb: "",
status,
});
const season = (status = "new"): PlexSeasonDetail => ({
id: "s1",
season_number: 1,
title: "S1",
thumb: "",
episode_count: 3,
status,
episodes: [],
});
afterEach(cleanup);
describe("PlexPosterCard a11y", () => {
it("names the hover row: the watched-toggle, add to playlist and the menu's '…'", () => {
render(
<PlexPosterCard
card={movie()}
onOpen={vi.fn()}
onToggleWatched={vi.fn()}
onAdd={vi.fn()}
menuButtonProps={() => ({})}
/>,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlistAdd.manage" })).toBeTruthy();
// Two: the hover row's and its touch twin beside the title (CSS shows one per device).
expect(screen.getAllByRole("button", { name: "card.menu.more" })).toHaveLength(2);
// Details moved into the menu: the card has no ⓘ of its own any more.
expect(screen.queryByRole("button", { name: "plex.info.title" })).toBeNull();
});
it("names the watched-toggle as mark-unwatched when the movie is watched", () => {
render(<PlexPosterCard card={movie("watched")} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
// The one media card — the year and the rating are the meta line under the title (pinned
// to the frame's bottom), the length a badge on the picture. The synopsis is NOT the picture's
// tooltip any more: it lives in the card's preview (below).
it("carries the year and rating in its meta line, the length on the picture", () => {
render(
<PlexPosterCard
card={{
...movie(),
year: 2023,
rating: 7.5,
duration_seconds: 6780,
summary: "A quiet synopsis.",
}}
onOpen={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByText("2023")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
expect(screen.getByText("1 hr 53 min")).toBeTruthy();
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
});
it("draws no meta line when the card has neither a year nor a rating", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), year: null, rating: null }} onOpen={vi.fn()} />,
);
expect(container.querySelector(".mt-auto")).toBeNull();
});
});
// The owner's pick C (2026-09-27): the synopsis shows when the MOUSE rests on the card — not on a
// sweep across the grid, and not on the keyboard walk (a panel at every stop).
describe("PlexPosterCard preview", () => {
afterEach(() => vi.useRealTimers());
const card = { ...movie(), title: "A long title", year: 2020, summary: "A quiet synopsis." };
const root = () =>
screen.getByRole("button", { name: "A long title" }).closest(".cv-poster") as HTMLElement;
it("opens once the mouse has rested on the card, with the whole title and the synopsis", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.mouseEnter(root());
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
act(() => {
vi.advanceTimersByTime(400);
});
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
});
it("does not open on keyboard focus", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
});
});
// Home's shelves: a show that plays on is a card for its NEXT episode (the owner, 2026-09-27).
describe("PlexPosterCard with a next episode", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
const next: PlexCard = { ...episode(), season_number: 6, episode_number: 16 };
const playGlyph = (c: HTMLElement) => c.querySelector(".reveal-hover.inset-0 svg");
it("draws the Play, names the episode, and acts on it; the ⓘ opens the show", () => {
const onToggleWatched = vi.fn();
const onAdd = vi.fn();
const onInfo = vi.fn();
const { container } = render(
<PlexPosterCard
card={show}
next={next}
onOpen={vi.fn()}
onToggleWatched={onToggleWatched}
onAdd={onAdd}
onInfo={onInfo}
/>,
);
expect(playGlyph(container)).not.toBeNull();
expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" }));
expect(onToggleWatched).toHaveBeenCalledWith(next);
// The episode's own add ("Add episode"), not the show's.
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
expect(onAdd).toHaveBeenCalledWith(next);
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
expect(onInfo).toHaveBeenCalled();
});
// Every show card plays (the owner, 2026-10-09): without its episode in hand the surface's onOpen
// asks which one. The toggles act on an episode, so they wait for one; the ⓘ is the show's page.
it("plays without one too, opens its page from the ⓘ, and offers no toggles", () => {
const onOpen = vi.fn();
const onPage = vi.fn();
const { container } = render(
<PlexPosterCard card={show} onOpen={onOpen} onPage={onPage} onToggleWatched={vi.fn()} />,
);
expect(playGlyph(container)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Show" }));
expect(onOpen).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
expect(onPage).toHaveBeenCalledWith(show);
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
});
it("continues a started show and starts its episode over, as a started movie does", () => {
const onOpen = vi.fn();
const onRestart = vi.fn();
render(
<PlexPosterCard
card={{ ...show, status: "in_progress", resumable: true }}
onOpen={onOpen}
onRestart={onRestart}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "card.restart" }));
expect(onRestart).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "card.continue" }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("plays a part-watched show with no episode to resume, with no Restart beside it", () => {
// Its next episode is untouched: Continue and Restart would both play it from its start.
render(
<PlexPosterCard
card={{ ...show, status: "in_progress", resumable: false }}
onOpen={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
expect(screen.queryByRole("button", { name: "card.continue" })).toBeNull();
});
it("names the episode it would play once its preview is up, and not asked before", async () => {
vi.useFakeTimers();
const ask = vi
.spyOn(api, "plexShowNext")
.mockResolvedValue({ next: { ...next, title: "Pilot" } });
render(<PlexPosterCard card={show} onOpen={vi.fn()} />);
fireEvent.mouseEnter(screen.getByRole("button", { name: "Show" }).closest(".cv-poster")!);
act(() => {
vi.advanceTimersByTime(600);
});
expect(ask).not.toHaveBeenCalled(); // a sweep across a grid asks nothing
await act(async () => {
vi.advanceTimersByTime(400);
});
vi.useRealTimers();
expect(ask).toHaveBeenCalledWith("sh", "en");
expect(await screen.findByText("plex.nextUp:S6·E16 — Pilot")).toBeTruthy();
ask.mockRestore();
});
});
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
// they sit beside the picture's own button, never inside it.
describe("a started Plex card", () => {
const started: PlexCard = {
...movie("in_progress"),
position_seconds: 600,
duration_seconds: 6000,
};
it("shows Continue + Restart, not the Play, and each does its own thing", () => {
const onOpen = vi.fn();
const onRestart = vi.fn();
const { container } = render(
<PlexPosterCard card={started} onOpen={onOpen} onRestart={onRestart} />,
);
const picture = screen.getByRole("button", { name: "M" });
expect(picture.querySelector(".reveal-hover.inset-0 svg")).toBeNull();
const cont = screen.getByRole("button", { name: "card.continue" });
const restart = screen.getByRole("button", { name: "card.restart" });
expect(picture.contains(cont) || picture.contains(restart)).toBe(false);
fireEvent.click(restart);
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onOpen).not.toHaveBeenCalled();
fireEvent.click(cont);
expect(onOpen).toHaveBeenCalledTimes(1);
// Mouse only: on touch the tap continues and the "…" has the restart.
expect(cont.closest(".reveal-hover")).not.toBeNull();
expect(container.querySelector(".flex-col.reveal-hover")).not.toBeNull(); // a poster stacks
});
it("an episode's landscape picture keeps the pair side by side", () => {
const { container } = render(
<EpisodeCard
ep={{ ...episode("in_progress"), position_seconds: 60, duration_seconds: 1200 }}
onPlay={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy();
expect(container.querySelector(".flex-col.reveal-hover")).toBeNull();
});
// The card read "started" off the rounded progress %, the menu off the position —
// 20 s into a 3 h film is 0 % here and started there, so the card offered no Restart the menu had.
it("a resume point under 1 % still offers the Restart, as the card's menu does", () => {
render(
<PlexPosterCard
card={{ ...movie("in_progress"), position_seconds: 20, duration_seconds: 10800 }}
onOpen={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy();
});
it("a not-started or watched card keeps the one Play", () => {
render(<PlexPosterCard card={movie("watched")} onOpen={vi.fn()} onRestart={vi.fn()} />);
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
expect(
screen.getByRole("button", { name: "M" }).querySelector(".reveal-hover.inset-0 svg"),
).not.toBeNull();
});
});
describe("SeasonCard a11y", () => {
it("names the watched-toggle and the add-season button", () => {
render(<SeasonCard se={season()} onOpen={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />);
expect(screen.getByRole("button", { name: "plex.series.markSeasonWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addSeason" })).toBeTruthy();
});
});
describe("EpisodeCard a11y", () => {
it("names the watched-toggle and the add-episode button", () => {
render(
<EpisodeCard ep={episode()} onPlay={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
});
});
// In a keyboard-walkable grid or rail the WHOLE card is the item (the ring goes round the
// poster and its title) and the poster — the card's own role=button — is what Enter opens.
describe("Plex cards in a keyboard-walkable list", () => {
const cases = [
[
"PlexPosterCard",
(onOpen: () => void, navIndex?: number) => (
<PlexPosterCard
card={movie()}
onOpen={onOpen}
onToggleWatched={vi.fn()}
navIndex={navIndex}
/>
),
],
[
"SeasonCard",
(onOpen: () => void, navIndex?: number) => (
<SeasonCard se={season()} onOpen={onOpen} navIndex={navIndex} />
),
],
[
"EpisodeCard",
(onOpen: () => void, navIndex?: number) => (
<EpisodeCard ep={episode()} onPlay={onOpen} navIndex={navIndex} />
),
],
] as const;
it.each(cases)("%s: the root is the item and the poster is its open element", (_, card) => {
const onOpen = vi.fn();
const { container } = render(card(onOpen, 3));
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute("data-nav-index")).toBe("3");
const open = root.querySelector<HTMLElement>("[data-nav-open]")!;
// A real button (ui/Pressable), not a div wearing its role: Enter/Space and the touch target
// come from the primitive (the picture was once found hand-built).
expect(open.tagName).toBe("BUTTON");
expect(open.getAttribute("type")).toBe("button");
fireEvent.click(open);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it.each(cases)("%s: outside a list it is no item", (_, card) => {
const { container } = render(card(vi.fn()));
expect(container.querySelector("[data-nav-index]")).toBeNull();
});
});
describe("a Plex card's title stays selectable", () => {
it("opens from its caption, but not on the click that ends selecting the title", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
const title = screen.getByText("M");
const range = document.createRange();
range.selectNodeContents(title);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
fireEvent.click(title, { detail: 1 });
expect(onOpen).not.toHaveBeenCalled();
window.getSelection()!.removeAllRanges();
fireEvent.click(title, { detail: 1 });
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
// The picture plays; the title, lit like a link, opens the page of what it names (owner).
describe("a Plex card's title is a link to its page", () => {
it("a movie's title opens its page and never plays it", () => {
const onOpen = vi.fn();
const onPage = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={onOpen} onPage={onPage} />);
fireEvent.click(screen.getByText("M"), { detail: 1 });
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "1", type: "movie" }));
expect(onOpen).not.toHaveBeenCalled();
});
it("an episode standing as its show opens the show", () => {
const onPage = vi.fn();
const ep: PlexCard = { ...episode(), show_id: "s9", show_title: "The Show" };
render(<PlexPosterCard card={ep} onOpen={vi.fn()} onPage={onPage} />);
fireEvent.click(screen.getByText("The Show"), { detail: 1 });
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "s9", type: "show" }));
});
it("an episode card's title opens the episode's page, its picture plays", () => {
const onPlay = vi.fn();
const onPage = vi.fn();
render(<EpisodeCard ep={episode()} onPlay={onPlay} onPage={onPage} />);
fireEvent.click(screen.getByText(episode().title), { detail: 1 });
expect(onPage).toHaveBeenCalledTimes(1);
expect(onPlay).not.toHaveBeenCalled();
});
it("without a page the title is plain text: a click does nothing", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
fireEvent.click(screen.getByText("M"), { detail: 1 });
expect(onOpen).not.toHaveBeenCalled();
expect(screen.getByText("M").className).not.toContain("cursor-pointer");
});
});
describe("a Plex card's caption and a drag", () => {
it("does not open on a press dragged past the slop", () => {
const onOpen = vi.fn();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
const title = screen.getByText("M");
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 });
expect(onOpen).not.toHaveBeenCalled();
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
fireEvent.click(title, { detail: 1, clientX: 11, clientY: 10 });
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
// On a shelf (Home, the Plex rails, a collection strip) the title is one line, so every card on the
// strip is the same height; a landscape shelf picture is short, so it takes the smaller Play, clear
// of the hover row under it (at the large size they touched on a 232px slot).
describe("PlexPosterCard on a shelf", () => {
const titleOf = () => screen.getByText("M");
const play = (c: HTMLElement) =>
c.querySelector("span[aria-hidden] > svg.lucide-play")!.parentElement!;
it("cuts the title to one line; a grid card keeps two", () => {
const { unmount } = render(<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf />);
expect(titleOf().className).toContain("truncate");
unmount();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} />);
expect(titleOf().className).toContain("line-clamp-2");
});
it("only a landscape SHELF card takes the smaller Play", () => {
const large = iconButtonClass({ variant: "media", size: "lg" });
const cases: [boolean, boolean, string][] = [
[true, true, iconButtonClass({ variant: "media", size: "md" })],
[true, false, large], // a poster on a shelf is tall enough
[false, true, large], // a landscape card in a grid is wide enough
];
for (const [shelf, wide, want] of cases) {
const { container, unmount } = render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf={shelf} wide={wide} />,
);
expect(play(container).className, `shelf=${shelf} wide=${wide}`).toBe(want);
unmount();
}
});
});
// An episode Plex has no number for (a special) is named by its title alone, as the player's own
// header names it — the cards printed a question mark where the number was missing.
describe("an episode card with no number for the episode", () => {
const special: PlexCard = {
...episode(),
title: "The Special",
show_id: "sh",
show_title: "Show",
season_number: 0,
episode_number: null,
};
it("names a show's episode by its title alone", () => {
render(<PlexPosterCard card={special} onOpen={vi.fn()} />);
expect(screen.queryByText("plex.episodeLine")).toBeNull();
expect(screen.getByText("The Special")).toBeTruthy();
});
it("names the next episode by its title alone", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
render(
<PlexPosterCard card={show} next={special} onOpen={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.getByText("plex.nextUp:The Special")).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.markNextWatched:The Special" })).toBeTruthy();
});
});
// A card fetched the detail page's 400px poster for its ~144px picture. It offers the
// proxy's small cut too and lets the browser pick by its width.
describe("a Plex card's picture", () => {
it("offers the small cut beside the full one, lazily", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), thumb: "/api/plex/image/1" }} onOpen={vi.fn()} />,
);
const img = container.querySelector("img")!;
expect(img.getAttribute("srcset")).toBe(
"/api/plex/image/1?variant=thumb_sm 240w, /api/plex/image/1 400w",
);
expect(img.getAttribute("sizes")).toBe("auto, 200px");
expect(img.getAttribute("loading")).toBe("lazy");
});
});
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
//
// What a Plex card says, in the reader's language. An episode in a poster rail stands as its show
// (the show's poster and name, the episode on the line under it), and a season Plex named generically
// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values,
// so an assertion can see which words went into the line.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render as rtlRender, screen, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
const t = (key: string, o?: Record<string, unknown>) =>
o ? `${key}(${Object.values(o).join("|")})` : key;
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t }) };
});
import { PlexPosterCard, SeasonCard } from "./plexCards";
import type { PlexCard, PlexSeasonDetail } from "../../lib/api";
// A show card asks the server which episode it would play once its preview is up: a query client.
const render = (ui: ReactElement) =>
rtlRender(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
),
});
afterEach(cleanup);
const watchedEpisode: PlexCard = {
id: "e5",
type: "episode",
title: "Visszaszámlálás",
thumb: "/api/plex/image/e5",
season_number: 2,
episode_number: 5,
show_title: "A 3-test-probléma",
show_id: "s9",
};
describe("an episode in a poster rail", () => {
it("stands as its show: the show's poster and name, the episode under it", () => {
render(<PlexPosterCard card={watchedEpisode} onOpen={vi.fn()} shelf />);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe("/api/plex/image/s9");
expect(screen.getByText("A 3-test-probléma")).toBeTruthy();
expect(screen.getByText("plex.episodeLine(2|5|Visszaszámlálás)")).toBeTruthy();
});
it("keeps its own landscape still where the rail is landscape, or the show is unknown", () => {
const { unmount } = render(<PlexPosterCard card={watchedEpisode} onOpen={vi.fn()} wide />);
expect(document.querySelector("img")?.getAttribute("src")).toBe("/api/plex/image/e5");
unmount();
render(<PlexPosterCard card={{ ...watchedEpisode, show_id: null }} onOpen={vi.fn()} />);
expect(document.querySelector("img")?.getAttribute("src")).toBe("/api/plex/image/e5");
expect(screen.getByText("Visszaszámlálás")).toBeTruthy();
});
});
const season = (title: string, n: number | null): PlexSeasonDetail => ({
id: "se",
season_number: n,
title,
thumb: "",
episode_count: 3,
status: "new",
episodes: [],
});
describe("a season's name", () => {
it.each([
["Season 1", 1, "plex.seasonTitle(1)"],
["Specials", 0, "plex.specials"],
["Book One: Water", 1, "Book One: Water"],
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
expect(screen.getByText(shown)).toBeTruthy();
});
});
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// A card's numbers follow a language switch. The formatters read the UI language, but the
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed
// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render as rtlRender, screen, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import { setLanguage } from "../../i18n";
import { PlexPosterCard } from "./plexCards";
import type { PlexCard } from "../../lib/api";
// A show card asks the server which episode it would play once its preview is up: a query client.
const render = (ui: ReactElement) =>
rtlRender(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
),
});
afterEach(() => {
cleanup();
setLanguage("en");
});
const movie: PlexCard = {
id: "m1",
type: "movie",
title: "Heat",
thumb: "/api/plex/image/m1",
duration_seconds: 2 * 3600 + 4 * 60,
rating: 8.3,
};
describe("a card after a language switch", () => {
it("says its runtime and rating in the new language", () => {
setLanguage("en");
render(<PlexPosterCard card={movie} onOpen={vi.fn()} />);
expect(screen.getByText("2 hr 4 min")).toBeTruthy();
act(() => setLanguage("hu"));
expect(screen.getByText("2 ó 4 p")).toBeTruthy();
expect(screen.getByText("8,3")).toBeTruthy();
});
});
@@ -0,0 +1,803 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
import clsx from "clsx";
import { type ReactNode, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { plexLang } from "../../i18n";
import { formatDecimal, formatRuntime } from "../../lib/format";
import { Carousel } from "../../components/ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../../lib/api";
import { episodeLine, episodeName, episodeNumbers, seasonTitle } from "../../lib/plexDetail";
import { navItemAttrs } from "../../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
import { IconButton } from "../../components/ui/IconButton";
import { MoreButton } from "../../components/ui/MoreButton";
import { Pressable } from "../../components/ui/Pressable";
import { HOVER_YIELD } from "../../components/ui/reveal";
import { MediaResumeButtons } from "../video/MediaResumeButtons";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_RESUME_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "../../components/ui/mediaCardStyles";
import { MediaCard, MediaProgress, MediaPlayGlyph } from "../../components/ui/MediaCard";
import { MediaPreview } from "../../components/ui/MediaPreview";
import { useMediaPreview } from "../../components/ui/useMediaPreview";
import { SURFACE } from "../../components/ui/surfaces";
import { cardImage } from "../../lib/plexImage";
import RemoteImage from "../../components/ui/RemoteImage";
// Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
// read is a show card asking which episode it would play, once its preview is up). Rendered by the
// browse grid, the person page, and the series show/season views.
//
// docs/DESIGN.md C1 ("Action placement"): a card shows its state (watched, progress) at rest;
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
// opens the title's one action list (`usePlexCardActions`); on touch those toggles are gone and a 44px
// "…" sits beside the title. The list's menu is the one door every device has.
/** A card's "…": this card's menu-button props (`useContextMenu.buttonProps`) and whether its menu
* is the open one. */
type More = { props: Record<string, unknown>; open: boolean } | undefined;
const more = <T,>(
row: T,
buttonProps: ((row: T) => Record<string, unknown>) | undefined,
open: boolean,
): More => (buttonProps ? { props: buttonProps(row), open } : undefined);
// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color
// alone (WCAG 1.4.1). Watched = CheckCircle2 (green), not-watched = Circle (empty, white).
function WatchedIcon({ watched }: { watched: boolean }) {
return watched ? (
<CheckCircle2 className="w-4 h-4 text-success" />
) : (
<Circle className="w-4 h-4 text-white" />
);
}
/** The hover row on a card's picture (bottom-right, as on the YouTube card). A sibling of the picture's own button, never
* inside it, so no control nests in another. */
function HoverBar({
watched,
watchedLabel,
onToggleWatched,
addLabel,
onAdd,
onInfo,
more: m,
}: {
watched: boolean;
watchedLabel: string;
onToggleWatched?: () => void;
addLabel: string;
onAdd?: () => void;
/** A show's own page: its picture plays an episode, so the page has a button of its own. */
onInfo?: () => void;
more: More;
}) {
const { t } = useTranslation();
if (!onToggleWatched && !onAdd && !onInfo && !m) return null;
return (
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
{onToggleWatched && (
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
<WatchedIcon watched={watched} />
</IconButton>
)}
{onAdd && (
<IconButton variant="media" onClick={onAdd} label={addLabel}>
<ListPlus />
</IconButton>
)}
{onInfo && (
<IconButton variant="media" onClick={onInfo} label={t("plex.openShow")}>
<Info />
</IconButton>
)}
{m && (
<MoreButton
variant="media"
label={t("card.menu.more")}
expanded={m.open}
menuProps={m.props}
/>
)}
</div>
);
}
/** The hover row on a picture that is not a card's (Home's hero): the card's own watched and add
* toggles and its "…", acting on a movie or an episode. */
export function PlexHoverBar({
card,
onToggleWatched,
onAdd,
moreProps,
moreOpen = false,
}: {
card: PlexCard;
onToggleWatched?: (c: PlexCard) => void;
onAdd?: (c: PlexCard) => void;
moreProps?: Record<string, unknown>;
moreOpen?: boolean;
}) {
const { t } = useTranslation();
const plays = card.type !== "show";
const watched = card.status === "watched";
return (
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={plays && onToggleWatched ? () => onToggleWatched(card) : undefined}
addLabel={t(plexAddLabelKey(card))}
onAdd={plays && onAdd ? () => onAdd(card) : undefined}
more={moreProps ? { props: moreProps, open: moreOpen } : undefined}
/>
);
}
/** A Plex card's title: a link to the page of what it names (a movie's or an episode's info page, a
* show's page), while the picture plays it — a title lit like a link that started playback surprised
* (owner). Not for the click that ends selecting its words, or a press dragged past the slop (DESIGN
* "Text stays selectable"); a div's text selects on its own, so only the click needs the guard.
* Without a page to open it is plain text. */
function CardTitle({
onClick,
shelf = false,
children,
}: {
onClick?: () => void;
/** One line, cut with an ellipsis (a shelf). The whole title is in the card's preview. */
shelf?: boolean;
children: ReactNode;
}) {
const pressedAt = useRef<{ x: number; y: number } | null>(null);
return (
<div
onClick={
onClick &&
((e) => {
if (!endsTextSelection(e) && !movedPastSlop(pressedAt.current, e)) onClick();
})
}
onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY };
}}
className={clsx(
"min-h-6",
mediaTitleClass(true, shelf),
onClick && "cursor-pointer hover:text-accent",
)}
>
{children}
</div>
);
}
/** A Plex card's picture: the whole box is the card's open/play button (Enter opens it from the
* keyboard walk), with the hover row docked beside it rather than inside it. A started item shows the
* video card's Continue + Restart pair instead of the Play (C5); like the hover row, the pair is a
* sibling laid over the picture, never inside its button. */
function PlexPicture({
aspect,
thumb,
label,
onOpen,
onRestart,
plays,
small = false,
pct = 0,
started = false,
bar,
children,
}: {
aspect: "poster" | "wide";
thumb: string;
/** The button's name: the picture has no text of its own. */
label: string;
onOpen: () => void;
/** Play from the start, where the surface can (the player's `fromStart`). */
onRestart?: () => void;
/** The click plays (a movie, an episode) — only then does the hover draw a Play. */
plays: boolean;
/** A shelf's short landscape picture: the smaller Play, clear of the hover row under it. */
small?: boolean;
pct?: number;
/** Has a resume point (`plexStarted`, the card menu's own rule): Continue + Restart, not Play. */
started?: boolean;
bar: ReactNode;
/** The badges printed on the picture. */
children?: ReactNode;
}) {
// Not read off `pct`: that is rounded, so 20 s into a 3 h film was 0 % and "not started" here while
// the menu offered "from the start". One rule, `plexStarted`, for both.
const resumable = plays && started && !!onRestart;
const pic = cardImage(thumb);
return (
<div className={mediaPictureBox(aspect)}>
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
<RemoteImage
srcSet={pic.srcSet}
sizes={pic.sizes}
src={pic.src}
alt=""
loading="lazy"
decoding="async"
className={MEDIA_IMG}
/>
{children}
<div className={MEDIA_HOVER_LAYER}>
{plays && !resumable && <MediaPlayGlyph small={small} />}
</div>
<MediaProgress pct={pct} />
</Pressable>
{resumable && (
<div
className={clsx(
MEDIA_RESUME_LAYER,
// A poster is too narrow for the pair side by side (150px); it stacks, as in the lab.
aspect === "poster" && "flex-col",
)}
>
<MediaResumeButtons small={small} onContinue={onOpen} onRestart={onRestart} />
</div>
)}
{bar}
</div>
);
}
const progressPct = (c: { position_seconds?: number; duration_seconds?: number | null }) =>
(c.position_seconds ?? 0) > 0 && c.duration_seconds
? Math.round(((c.position_seconds ?? 0) / c.duration_seconds) * 100)
: 0;
export function PlexPosterCard({
card,
onOpen,
onToggleWatched,
onAdd,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
wide = false,
shelf = false,
next,
onInfo,
onRestart,
onPage,
}: {
card: PlexCard;
onOpen: () => void;
/** Open a card's page (a show's, or a movie's or an episode's info page): the title's link. It is
* handed the card the title NAMES — the show, for an episode standing as its show. */
onPage?: (target: PlexCard) => void;
/** Play the item (or the episode a show plays) from its start, not its resume point (C5). */
onRestart?: () => void;
onToggleWatched?: (c: PlexCard) => void;
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
onAdd?: (c: PlexCard) => void;
/** Right-click / long-press props for the list's ONE context menu. A function of the card rather
* than a ready-made object, so every card surface takes `menuProps={menu.triggerProps}`. */
menuProps?: (c: PlexCard) => Record<string, unknown>;
/** The same menu's "…" (`useContextMenu.buttonProps`), on the picture on hover and beside the
* title on touch. */
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
/** This card's menu is the open one (the "…" says so with `aria-expanded`). */
menuOpen?: boolean;
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
navIndex?: number;
/** Landscape, beside YouTube cards (Home's shelves): the item's wide art instead of its poster
* (the poster, cropped, when it has no art). An episode's picture is landscape already. */
wide?: boolean;
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
shelf?: boolean;
/** A SHOW's episode, resolved by the surface (Home's shelves carry it): the watched toggle and the
* add act on it and the meta line names it. Without it a show still plays — the surface's `onOpen`
* asks the server which episode (`/show/{id}/next`) — and the preview names that episode once the
* pointer has rested on the card. */
next?: PlexCard | null;
/** The show's page, on the ⓘ (`onPage` where a surface has no other). */
onInfo?: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const preview = useMediaPreview();
// An episode in a poster rail (Discover's "Recently watched") stands as its SHOW: the show's poster
// and name, the episode on the line under it (option A). Its own 16:9 still in a poster slot
// was a cropped frame titled only "Countdown". Needs the show (`show_id`, from a leaf card).
const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title;
const landscape = wide || (card.type === "episode" && !asShow);
const name = asShow ? `${card.show_title} · ${card.title}` : card.title;
const titled: PlexCard = asShow
? { ...card, type: "show", id: card.show_id!, title: card.show_title! }
: card;
const isShow = card.type === "show";
const ep = isShow ? (next ?? null) : null;
// Every picture plays: a show its next episode. The hover toggles act on a leaf, so a show offers
// them only with its episode in hand.
const isPlayable = !isShow || !!ep;
const plang = plexLang(lang);
// Which episode a show would play, for the preview: asked once the pointer rests on the card, and
// fresh every time (staleTime 0), since a watch anywhere moves it.
const upNextQ = useQuery({
queryKey: qk.plexShowNext(card.id, plang),
queryFn: () => api.plexShowNext(card.id, plang),
enabled: isShow && !ep && preview.open,
staleTime: 0,
});
const upNext = ep ?? upNextQ.data?.next ?? null;
// What the hover row acts on: the episode a show would play, else the card itself.
const acted = ep ?? card;
const watched = card.status === "watched";
const actedWatched = acted.status === "watched";
const runtime = formatRuntime(card.duration_seconds, lang);
const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
// The next episode by name: its numbers, or its title when Plex has none for it.
const nextName = ep && episodeName(ep);
const started = plexStarted(ep ?? card);
const showPage = ep ? onInfo : isShow && onPage ? () => onPage(card) : undefined;
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
const corner =
card.type === "show"
? card.season_count != null && t("plex.seasons", { count: card.season_count })
: runtime;
const rating = card.rating != null && (
<span className="inline-flex items-center gap-0.5">
<Star className="w-3 h-3 fill-current text-rating" aria-hidden />
{formatDecimal(card.rating, lang)}
</span>
);
return (
<>
<MediaCard
dense
rootProps={preview.rootProps({
...menuProps?.(card),
...(navIndex !== undefined && navItemAttrs(navIndex, name)),
})}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
// a carousel turns it off (index.css `.media-rail`).
className="cv-poster"
picture={
<PlexPicture
aspect={landscape ? "wide" : "poster"}
thumb={
asShow
? `/api/plex/image/${encodeURIComponent(card.show_id ?? "")}`
: (wide && card.type !== "episode" && card.art) || card.thumb
}
label={name}
onOpen={onOpen}
onRestart={onRestart}
plays
small={shelf && landscape}
pct={pct}
started={started}
bar={
<HoverBar
watched={actedWatched}
watchedLabel={
nextName
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", {
name: nextName,
})
: actedWatched
? t("plex.markUnwatched")
: t("plex.markWatched")
}
onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(acted))}
onAdd={add}
onInfo={showPage}
more={m}
/>
}
>
{watched && <WatchedBadge />}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && <InProgressBadge />}
{corner && <CornerBadge yields={!!(toggle || add || m)}>{corner}</CornerBadge>}
</PlexPicture>
}
title={
<CardTitle onClick={onPage && (() => onPage(titled))} shelf={shelf}>
{asShow ? card.show_title : card.title}
</CardTitle>
}
meta={
asShow ? (
<div className="truncate text-xs leading-6 text-muted">
{episodeNumbers(card)
? t("plex.episodeLine", { ...episodeNumbers(card)!, title: card.title })
: card.title}
</div>
) : nextName ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.nextUp", { name: nextName })}
</div>
) : (
(!!card.year || card.rating != null) && (
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
{!!card.year && <span>{card.year}</span>}
{!!card.year && rating && <span aria-hidden>·</span>}
{rating}
</div>
)
)
}
more={m}
moreLabel={t("card.menu.more")}
/>
<MediaPreview
preview={preview}
title={name}
facts={
<span className="flex flex-wrap items-center gap-x-1.5">
{[card.year, corner].filter(Boolean).join(" · ")}
{rating && (!!card.year || !!corner) && <span aria-hidden>·</span>}
{rating}
</span>
}
note={upNext && t("plex.nextUp", { name: episodeLine(upNext) })}
summary={card.summary}
/>
</>
);
}
/** The picture's rest-state marks, in the corners the hover row leaves free (it docks bottom-right). */
function WatchedBadge() {
const { t } = useTranslation();
return <span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5")}>{t("plex.watched")}</span>;
}
function InProgressBadge() {
const { t } = useTranslation();
return (
<span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5 text-accent")}>
{t("plex.inProgress")}
</span>
);
}
/** The bottom-right fact (a length, a count): it steps aside while the hover row covers its corner;
* at rest, and on touch, it stays. */
function CornerBadge({ yields, children }: { yields: boolean; children: ReactNode }) {
return (
<span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", yields && HOVER_YIELD)}>
{children}
</span>
);
}
export function SeasonCard({
se,
onOpen,
onToggleWatched,
onAdd,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
se: PlexSeasonDetail;
onOpen: () => void;
onToggleWatched?: () => void;
onAdd?: () => void;
/** The show page's season menu (see `PlexPosterCard`). */
menuProps?: (se: PlexSeasonDetail) => Record<string, unknown>;
menuButtonProps?: (se: PlexSeasonDetail) => Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const title = seasonTitle(se, t);
const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen);
return (
<MediaCard
dense
rootProps={{
...menuProps?.(se),
...(navIndex !== undefined && navItemAttrs(navIndex, title)),
}}
picture={
// A season opens its episode list: no Play on hover, since the click does not play.
<PlexPicture
aspect="poster"
thumb={se.thumb}
label={title}
onOpen={onOpen}
plays={false}
bar={
<HoverBar
watched={watched}
watchedLabel={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addSeason")}
onAdd={onAdd}
more={m}
/>
}
>
{watched && <WatchedBadge />}
{/* A season has no progress bar, so this badge is its sole in-progress cue. */}
{se.status === "in_progress" && <InProgressBadge />}
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>
{t("plex.series.episodeCount", { count: se.episode_count })}
</CornerBadge>
</PlexPicture>
}
title={<CardTitle onClick={onOpen}>{title}</CardTitle>}
more={m}
moreLabel={t("card.menu.more")}
/>
);
}
export function CastStrip({
cast,
onFilter,
onOpenPerson,
}: {
cast: PlexCastMember[];
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
return (
<section
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<CastRow cast={cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
</section>
);
}
// The ONE cast carousel: a show's page (CastStrip) and a title's info (PlexInfo, page or the
// player's overlay) each frame it with their own heading. Two hand copies of it had drifted apart
// (gap, lazy pictures, the name's tooltip). `tone="overlay"` is the white-on-art palette.
export function CastRow({
cast,
tone = "panel",
onFilter,
onOpenPerson,
}: {
cast: PlexCastMember[];
tone?: "panel" | "overlay";
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const clickable = !!(onOpenPerson || onFilter);
const overlay = tone === "overlay";
return (
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")} keyboardNav={clickable}>
{cast.map((c, i) => {
const inner = (
<>
<div
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
<RemoteImage
src={c.thumb}
alt=""
loading="lazy"
className="h-full w-full object-cover"
fallback={
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
}
/>
</div>
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
same text height and the row of circles sits on one baseline regardless of name length. */}
<div
className={`mt-1.5 truncate text-xs font-medium leading-tight ${overlay ? "text-white/90" : ""} ${
clickable ? "group-hover/cast:text-accent" : ""
}`}
title={c.name}
>
{c.name}
</div>
{c.role && (
<div
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
title={c.role}
>
{c.role}
</div>
)}
</>
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() => (onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] }))}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0"
>
{inner}
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</Carousel>
);
}
export function RelatedStrip({
related,
onOpen,
onPlay,
onRestart,
menuProps,
menuButtonProps,
openId = null,
}: {
related: PlexCard[];
/** A related show's page (its title and its ⓘ). */
onOpen: (c: PlexCard) => void;
/** Its picture: the episode it would play, as on every show card. */
onPlay: (c: PlexCard) => void;
onRestart: (c: PlexCard) => void;
/** The page's Plex menu (a related show is a Plex card like any other: DESIGN.md C1). */
menuProps?: (c: PlexCard) => Record<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
/** The id whose menu is open. */
openId?: string | null;
}) {
const { t } = useTranslation();
return (
<section
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => (
<div key={r.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
card={r}
shelf
navIndex={i}
onOpen={() => onPlay(r)}
onRestart={() => onRestart(r)}
onPage={onOpen}
menuProps={menuProps}
menuButtonProps={menuButtonProps}
menuOpen={openId === r.id}
/>
</div>
))}
</Carousel>
</section>
);
}
export function EpisodeCard({
ep,
onPlay,
onRestart,
onAdd,
onToggleWatched,
withShowTitle,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
onPage,
}: {
ep: PlexCard;
onPlay: () => void;
/** The episode's info page: the title's link (the picture plays). */
onPage?: () => void;
/** Play the episode from its start, not its resume point (C5). */
onRestart?: () => void;
onAdd?: () => void;
onToggleWatched?: () => void;
withShowTitle?: boolean;
/** The episode list's menu (see `PlexPosterCard`). */
menuProps?: (c: PlexCard) => Record<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const watched = ep.status === "watched";
const pct = watched ? 0 : progressPct(ep);
const m = more(ep, menuButtonProps, menuOpen);
const runtime = formatRuntime(ep.duration_seconds, lang);
return (
<MediaCard
dense
rootProps={{
...menuProps?.(ep),
...(navIndex !== undefined && navItemAttrs(navIndex, ep.title)),
}}
picture={
<PlexPicture
aspect="wide"
thumb={ep.thumb}
label={ep.title}
onOpen={onPlay}
onRestart={onRestart}
plays
pct={pct}
started={plexStarted(ep)}
bar={
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
}
>
{watched && <WatchedBadge />}
{runtime && (
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>{runtime}</CornerBadge>
)}
</PlexPicture>
}
title={
<CardTitle onClick={onPage}>
{!withShowTitle && ep.episode_number != null && (
<span className="text-muted mr-1">{ep.episode_number}.</span>
)}
{ep.title}
</CardTitle>
}
meta={
withShowTitle &&
ep.show_title && (
<div className="truncate text-xs text-muted">
{ep.show_title}
{episodeNumbers(ep)
? ` · S${episodeNumbers(ep)!.season}·E${episodeNumbers(ep)!.episode}`
: ""}
</div>
)
}
more={m}
moreLabel={t("card.menu.more")}
/>
);
}
@@ -0,0 +1,169 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
import { pinPlex, useComfortPin } from "../comfort/comfortPin";
import { useCopyItems } from "../../components/ui/copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
// lists the same actions in the same order. The card keeps its state (watched, progress) at rest and
// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
export interface PlexActionHandlers {
/** Play it: a show, the episode it would play (the surface resolves it). */
onOpen: (c: PlexCard) => void;
/** The media-info page, or a show's own page. Omitted where a surface has none (the episode
* lists). */
onInfo?: (c: PlexCard) => void;
onToggleWatched?: (c: PlexCard) => void;
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
* under Play — on touch, the only way to it (the card's hover pair is mouse-only). */
onRestart?: (c: PlexCard) => void;
}
const icon = "w-4 h-4";
/** The "add to playlist" row and button: an episode says which it adds, as the season page does. */
export const plexAddLabelKey = (c: PlexCard): string =>
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage";
/** A leaf: a movie or an episode, what the watched toggle, the playlist add and a resume point
* belong to. A show plays too, through the episode it resolves to. */
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
* stored at 0, so it never counts. A show has one when an episode does (`resumable`, the episode its
* click plays): part watched with its next episode untouched, Continue and Restart would be the same
* thing, so it is not "started" here — its in-progress badge says how far it has got. */
export const plexStarted = (c: PlexCard): boolean =>
isPlexPlayable(c)
? c.status !== "watched" && (c.position_seconds ?? 0) > 0
: c.resumable === true;
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
* (one per list, so a card does not mount a modal of its own). */
export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) {
const { t } = useTranslation();
const pin = useComfortPin(open);
const copy = useCopyItems();
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(null);
// Collections are shared and written to Plex itself, so only an admin edits them (as on the title's
// own page).
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
// A whole show needs its episode keys, which only the show page has ("Add whole show" there), so
// only a leaf is added from a card.
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
/** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`);
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind;
* `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside —
* without one the row is left out rather than opening the panel at a corner. Required, so every
* host menu decides: an optional one let a menu that has an anchor drop the row silently. */
const items = (
c: PlexCard,
{
play = true,
pin: withPin = true,
anchor,
}: { play?: boolean; pin?: boolean; anchor: Element | null },
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
const isShow = c.type === "show";
const watched = c.status === "watched";
const started = plexStarted(c);
return [
...(play
? [
{
id: "play",
label: started ? t("card.continue") : t("plex.play"),
icon: <Play className={icon} />,
onSelect: () => onOpen(c),
},
// The same words and glyph as the video card's menu (useVideoActions).
...(started && onRestart
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className={icon} />,
onSelect: () => onRestart(c),
},
]
: []),
]
: []),
...(playable && onToggleWatched
? [
{
id: "watched",
label: watched ? t("plex.markUnwatched") : t("plex.markWatched"),
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
separatorBefore: true,
onSelect: () => onToggleWatched(c),
},
]
: []),
...(playable
? [
{
id: "playlist",
label: addLabel(c),
icon: <ListPlus className={icon} />,
separatorBefore: !onToggleWatched,
onSelect: () => add(c),
},
]
: []),
// A movie or a show can be a collection member; an episode cannot.
...(isAdmin && anchor && c.type !== "episode"
? [
{
id: "collection",
label: t("plex.collectionAdd.row"),
icon: <Layers className={icon} />,
separatorBefore: !playable,
onSelect: () => setCollecting({ ratingKey: c.id, anchor }),
},
]
: []),
...(onInfo
? [
{
id: "details",
label: isShow ? t("plex.openShow") : t("plex.info.title"),
icon: isShow ? <Tv2 className={icon} /> : <Info className={icon} />,
separatorBefore: true,
onSelect: () => onInfo(c),
},
]
: []),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
...(withPin ? pin.item(pinPlex(c), c.title) : []),
];
};
const panel = (
<>
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
{collecting && (
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
)}
</>
);
return { items, add, panel };
}
export type PlexCardActions = ReturnType<typeof usePlexCardActions>;
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// The Settings prefs are seeded from the account's server prefs in the `me` cache — BootGate mounts the
// tree only once it is there. The adopt effect this replaced corrected them AFTER mounting; with it
// gone, a seed that read only this browser's copy would never show a hints setting changed on another
// device. And the seed is not an edit: it must not be saved straight back. Performance mode is the
// exception: it is this device's, so the server's word on it is not taken and a change is not sent.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PrefsProvider } from "./PrefsProvider";
import { usePrefs } from "./prefsContext";
import { api } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys";
const seen: { perf: boolean; hints: boolean }[] = [];
const controls: ReturnType<typeof usePrefs>[] = [];
function Probe() {
const prefs = usePrefs();
controls.push(prefs);
seen.push({ perf: prefs.perf, hints: prefs.hints });
return null;
}
function mount(preferences: Record<string, unknown>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences });
render(
<QueryClientProvider client={qc}>
<PrefsProvider>
<Probe />
</PrefsProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
seen.length = 0;
controls.length = 0;
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("PrefsProvider seed", () => {
it("renders the account's server prefs from the first render, and does not save them back", () => {
localStorage.setItem(LS.hints, "1");
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
mount({ hints: false });
expect(seen[0]?.hints).toBe(false);
// past the auto-save debounce
act(() => {
vi.advanceTimersByTime(1000);
});
expect(save).not.toHaveBeenCalled();
});
it("takes performance mode from this device, never from the account", () => {
localStorage.setItem(LS.perfMode, "1"); // this device has it on
mount({ performanceMode: false }); // an older build saved the account's value
expect(seen[0]?.perf).toBe(true);
cleanup();
localStorage.setItem(LS.perfMode, "0");
mount({ performanceMode: true });
expect(seen[seen.length - 1]?.perf).toBe(false);
});
it("keeps a performance mode change on this device: nothing is sent to the server", () => {
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
mount({});
act(() => controls[controls.length - 1]?.setPerf(true));
act(() => {
vi.advanceTimersByTime(1000);
});
expect(localStorage.getItem(LS.perfMode)).toBe("1");
expect(document.documentElement.dataset.perf).toBe("1");
expect(save).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,159 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { qk } from "../../lib/queryKeys";
import { useQueryClient } from "@tanstack/react-query";
import { api, type Me } from "../../lib/api";
import {
applyTheme,
loadLocalTheme,
normalizeTheme,
saveLocalTheme,
type ThemePrefs,
} from "../../lib/theme";
import {
configureNotifications,
getNotifSettings,
type NotifSettings,
} from "../../lib/notifications";
import { hintsEnabled, setHintsEnabled } from "../../lib/hints";
import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
import type { PrefsController } from "../../modules/settings/SettingsPanel";
import { PrefsContext } from "./prefsContext";
const PERF_KEY = LS.perfMode;
// The Settings page's account preferences. They apply locally for instant preview and auto-save
// (debounced) to the server — there is no draft or Save button. Performance mode is held here too but
// is this device's (a weak GPU wants it, a strong one does not), so it stays in this browser only. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
type EditablePrefs = {
theme: ThemePrefs;
hints: boolean;
railLabels: boolean;
notifications: NotifSettings;
};
export function PrefsProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
// The account's prefs, seeded straight from the `me` cache — BootGate mounts this only once /api/me
// has answered, so a theme changed on another device is right on the first paint instead of
// switching after it. This browser's copy fills any key the server has never stored.
const [seed] = useState<EditablePrefs>(() => {
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
return {
theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
// B7: off by default — the icon rail with name tooltips is the house look.
railLabels: prefs?.railLabels === true,
notifications: prefs?.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
});
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(seed.hints);
const [railLabels, setRailLabels] = useState(seed.railLabels);
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
// The last server-known prefs: the seed, then each successful save. The auto-save effect compares
// against it, so the seed itself is never echoed back as a save. A ref (drives no UI).
const baselineRef = useRef<EditablePrefs>(seed);
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(undefined);
// Live-apply the prefs locally for instant preview (their localStorage mirrors update via the
// stores too); persistence to the server is the auto-save effect below. The look is applied in
// LAYOUT effects, before the browser paints, so the app's first frame is already in the account's
// theme rather than in the baseline one main.tsx sets for the boot screen.
useLayoutEffect(() => applyTheme(theme), [theme]);
useLayoutEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
// theme-appropriate image on <body> and switches the glass to translucent.
useLayoutEffect(() => {
const on = theme.bgImage && !perf;
document.documentElement.dataset.backdrop = on ? "on" : "off";
}, [theme.bgImage, perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
// Auto-save on change (debounced ~500ms so a slider drag coalesces into one PUT). Live-apply is the
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
// saved straight back (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, hints, railLabels, notifications: notif }),
[theme, hints, railLabels, notif],
);
useEffect(() => {
window.clearTimeout(saveTimer.current);
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
// Back at the last-saved value (the mount seed, or an edit undone) — nothing to
// save; clear any "Saving…" left from a change that was reverted before it flushed.
setSaveState((s) => (s === "saving" ? "idle" : s));
return;
}
setSaveState("saving");
window.clearTimeout(saveMsgTimer.current);
const payload = editablePrefs;
saveTimer.current = window.setTimeout(() => {
api
.savePrefs(payload)
.then(() => {
baselineRef.current = payload;
setSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure; flag the indicator so the user sees it didn't take.
setSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 4000);
});
}, 500);
return () => window.clearTimeout(saveTimer.current);
}, [editablePrefs]);
// Clear pending timers on unmount so a debounced PUT or a fade timer never fires setState on a dead
// component.
useEffect(
() => () => {
window.clearTimeout(saveTimer.current);
window.clearTimeout(saveMsgTimer.current);
},
[],
);
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
const setTheme = useCallback((next: ThemePrefs) => {
setThemeState(next);
saveLocalTheme(next);
}, []);
const value = useMemo<PrefsController>(
() => ({
theme,
setTheme,
perf,
setPerf,
hints,
setHints,
railLabels,
setRailLabels,
notif,
setNotif,
saveState,
}),
[theme, setTheme, perf, hints, railLabels, notif, saveState],
);
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
}
@@ -0,0 +1,25 @@
import { createContext, useContext } from "react";
import { DEFAULT_THEME } from "../../lib/theme";
import { getNotifSettings } from "../../lib/notifications";
import type { PrefsController } from "../../modules/settings/SettingsPanel";
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
// components (Fast Refresh).
export const PrefsContext = createContext<PrefsController>({
theme: DEFAULT_THEME,
setTheme: () => {},
perf: false,
setPerf: () => {},
hints: true,
setHints: () => {},
railLabels: false,
setRailLabels: () => {},
notif: getNotifSettings(),
setNotif: () => {},
saveState: "idle",
});
export function usePrefs(): PrefsController {
return useContext(PrefsContext);
}
@@ -0,0 +1,187 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { ListPlus } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { playlistName } from "../../lib/playlistName";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton";
import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
export default function AddToPlaylist({
videoId,
variant = "ghost",
size = "sm",
}: {
videoId: string;
variant?: "ghost" | "outline" | "overlay" | "media";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useMembershipPopover(open, setOpen);
const { setReference } = pop.refs;
return (
<>
<IconButton
ref={setReference}
variant={variant}
size={size}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
label={t("playlists.addToPlaylist")}
>
<ListPlus />
</IconButton>
<MembershipPanel videoId={videoId} pop={pop} />
</>
);
}
/** The same panel, opened from an object's menu ("Add to playlist…") rather than its own
* button: anchored to the element the menu came from, and gone when it closes. */
export function AddToPlaylistAt({
videoId,
anchor,
onClose,
}: {
videoId: string;
anchor: Element;
onClose: () => void;
}) {
const pop = useMembershipPopover(true, (next) => {
if (!next) onClose();
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
return <MembershipPanel videoId={videoId} pop={pop} />;
}
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
return useAnchoredPopover({
open,
onOpenChange,
offset: 6,
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
// roving list so they don't steal the caret.
hostsInput: true,
});
}
function MembershipPanel({
videoId,
pop,
}: {
videoId: string;
pop: ReturnType<typeof useAnchoredPopover>;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const open = pop.open;
const [busy, setBusy] = useState(false);
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId),
enabled: open,
});
function refresh() {
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
// Also invalidate every open/cached playlist detail (["playlist", id]) so the
// Playlists page reflects the add/remove when navigated to, not only after F5.
qc.invalidateQueries({ queryKey: qk.playlist() });
}
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that
// landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it.
async function toggle(pl: Playlist) {
if (busy) return;
setBusy(true);
try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId);
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
);
} catch (e) {
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
}
refresh();
setBusy(false);
}
async function createAndAdd(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
made = true;
} catch (e) {
notifyActionError(e, t("playlists.addFailed"));
}
refresh();
setBusy(false);
return made;
}
// All of the user's playlists are add targets, including YouTube-linked ones — adding
// marks them dirty, and a later "Sync to YouTube" pushes the change back.
const lists = membership.data ?? [];
return (
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-60 p-2`}
ariaLabel={t("playlists.addToPlaylistTitle")}
listInside
>
<MembershipMenu
heading={t("playlists.addToPlaylistTitle")}
rows={lists.map((pl) => ({
id: pl.id,
label: playlistName(pl, t),
checked: !!pl.has_video,
extra:
pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
) : undefined,
}))}
onToggle={(row) => {
const pl = lists.find((p) => p.id === row.id);
if (pl) void toggle(pl);
}}
status={
membership.isError && !membership.data
? "error"
: membership.isLoading
? "loading"
: "ready"
}
onRetry={() => void membership.refetch()}
emptyText={t("playlists.noneYet")}
errorText={t("common.loadError")}
busy={busy}
create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }}
/>
</PopoverSurface>
);
}
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// Reported from daily use: middle-clicking the channel name in the YT player did nothing. It looked
// like a link — the YouTube icon beside it IS one — but it was a `<button>`, so the browser had no
// href to open a tab with. These pin the two halves the fix has to keep true at once: there is a real
// href pointing where the in-app navigation would go, AND a plain left click still takes the in-app
// path (which does more than navigate — the player has to close and reclaim its history entry first).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
import ChannelNameLink from "./ChannelNameLink";
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
beforeEach(() => {
// No global auto-cleanup in this project's vitest setup, and several cases render more than once.
cleanup();
window.history.replaceState({}, "", "/?page=feed&show=unwatched");
});
const renderLink = () => {
const onOpenInApp = vi.fn();
render(<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={onOpenInApp} />);
return { link: screen.getByText("LEMMiNO") as HTMLAnchorElement, onOpenInApp };
};
/** Dispatch a click and report whether the component prevented it (i.e. handled it in-app).
* The document-level guard runs AFTER React's handler and swallows the default, so an
* un-prevented click on a real href does not make jsdom log "Not implemented: navigation". */
const clickWasHandled = (el: HTMLElement, init: MouseEventInit = {}): boolean => {
let prevented = false;
const guard = (e: Event) => {
prevented = e.defaultPrevented;
e.preventDefault();
};
document.addEventListener("click", guard);
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
document.removeEventListener("click", guard);
return prevented;
};
describe("ChannelNameLink", () => {
it("is an anchor whose href opens this channel on the current page", () => {
const { link } = renderLink();
expect(link.tagName).toBe("A");
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
});
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
const { link, onOpenInApp } = renderLink();
expect(clickWasHandled(link, { button: 0 })).toBe(true);
expect(onOpenInApp).toHaveBeenCalledTimes(1);
});
it.each([
["middle-click", { button: 1 }], // the reported gesture
["ctrl-click", { ctrlKey: true }],
["cmd-click", { metaKey: true }],
["shift-click", { shiftKey: true }],
// Alt/Option-click on a link is "download the target" — the one arm no test dispatched, so
// dropping it from the shared predicate hijacked it on every card thumbnail too.
["alt-click", { altKey: true }],
])("leaves %s to the browser", (_name, mod) => {
const { link, onOpenInApp } = renderLink();
// Neither hijacked in-app nor prevented — the href is left to do its job.
expect(clickWasHandled(link, mod)).toBe(false);
expect(onOpenInApp).not.toHaveBeenCalled();
});
it("does not lose the rest of the address bar", () => {
window.history.replaceState({}, "", "/?page=channels&q=abc");
const { link } = renderLink();
expect(link.getAttribute("href")).toContain("page=channels");
expect(link.getAttribute("href")).toContain("q=abc");
});
it("gives a channel with no title yet an accessible name", () => {
// A channel first seen through a live search can be a stub with no title. The anchor used to
// render empty — still in the tab order, announced by nothing but its href.
render(<ChannelNameLink channelId={CH} title={null} onOpenInApp={() => {}} />);
expect(screen.getByRole("link", { name: "player.channel" })).toBeTruthy();
});
it("keeps every click from a clickable ancestor when asked to — the browser's gestures too", () => {
// The video card's row reacts to clicks; its channel name must not trigger it, whichever way it
// is clicked.
const row = vi.fn();
render(
<div onClick={row}>
<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={() => {}} stopPropagation />
</div>,
);
const link = screen.getByText("LEMMiNO");
// The bubbling guard in clickWasHandled never sees these (that is the point), so stop the
// browser's navigation in the capture phase instead.
const noNav = (e: Event) => e.preventDefault();
document.addEventListener("click", noNav, true);
for (const init of [{ button: 0 }, { ctrlKey: true }, { button: 1 }])
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
document.removeEventListener("click", noNav, true);
expect(row).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
import { forwardRef, type AnchorHTMLAttributes } from "react";
import { useTranslation } from "react-i18next";
import { isPlainLeftClick } from "../../lib/nav";
import { urlWithParams } from "../../lib/urlState";
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
// card's channel name had the same `<button>`, so it renders through here too.
//
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
// the SPA path does more than navigate (the player has to close first and reclaim its own
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
channelId: string;
title: string | null | undefined;
/** The in-app navigation, run only for a plain left click. */
onOpenInApp: () => void;
/** The native tooltip (`title` is taken by the channel's name). */
tooltip?: string;
/** Keep EVERY click on the link — the browser's own gestures too — from reaching a clickable
* ancestor (the video card's row). */
stopPropagation?: boolean;
};
const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNameLink(
{ channelId, title, onOpenInApp, tooltip, stopPropagation, ...rest },
ref,
) {
const { t } = useTranslation();
return (
<a
{...rest}
ref={ref}
href={urlWithParams({ chan: channelId })}
title={tooltip}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
if (!isPlainLeftClick(e)) return; // let the browser open its tab/window
e.preventDefault();
onOpenInApp();
}}
>
{/* A channel first seen through a live search can have no title yet. An empty anchor would
still be in the tab order, announced by nothing but its href. */}
{title || t("player.channel")}
</a>
);
});
export default ChannelNameLink;
@@ -0,0 +1,66 @@
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { Play, RotateCcw } from "lucide-react";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
// The started card's hover pair (DESIGN "Players", C5: restarting lives on the card): Continue in the
// accent, Restart dark translucent. ONE component for the YouTube card and the Plex cards, so the one
// media card says the same two words in the same two looks. A small picture (a shelf's landscape, the
// dense video card) takes the icon-only pair, which fits beside the hover row. Touch has no hover: a
// tap on the picture continues, and the card's "…" carries the restart.
export function MediaResumeButtons({
small = false,
onContinue,
onRestart,
}: {
small?: boolean;
onContinue: (e: MouseEvent) => void;
onRestart: (e: MouseEvent) => void;
}) {
const { t } = useTranslation();
return small ? (
<>
<IconButton
variant="primary"
size="md"
round
onClick={onContinue}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
<IconButton
variant="media"
size="md"
onClick={onRestart}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
</>
) : (
<>
<Button
size="sm"
round
onClick={onContinue}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
<Button
variant="media"
size="sm"
onClick={onRestart}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
</>
);
}
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
//
// The card shows its state and the everyday toggles (watched, save, add to playlist) and a
// "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
// picture, and beside the title for touch — only one is displayed per device) points both at the
// same menu.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
import VideoCard from "./VideoCard";
import type { Video } from "../../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(cleanup);
const video = {
id: "abc123",
title: "Cicada 3301",
channel_id: "c1",
channel_title: "LEMMiNO",
status: "new",
saved: false,
position_seconds: 40,
duration_seconds: 1200,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=abc123",
thumbnail_url: "",
} as unknown as Video;
function renderCard(view: FeedView, menuOpen = false) {
const buttonProps = vi.fn(() => ({ "aria-haspopup": "menu" }));
render(
<QueryClientProvider client={new QueryClient()}>
<VideoCard
video={video}
view={view}
width={0}
onState={() => {}}
onToggleSave={() => {}}
onOpen={() => {}}
menuButtonProps={buttonProps}
menuOpen={menuOpen}
/>
</QueryClientProvider>,
);
return { buttonProps };
}
const names = () => screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"));
describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions", (view) => {
it("are the toggles and the '…' — nothing that belongs to the menu", () => {
renderCard(view);
const all = names();
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
expect(all).toContain(want);
expect(all).toContain("card.menu.more");
for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"])
expect(all).not.toContain(gone);
});
it("points every '…' at the menu, and says when it is open", () => {
const { buttonProps } = renderCard(view, true);
const mores = screen.getAllByRole("button", { name: "card.menu.more" });
expect(buttonProps).toHaveBeenCalledWith(video);
for (const m of mores) {
expect(m.getAttribute("aria-haspopup")).toBe("menu");
expect(m.getAttribute("aria-expanded")).toBe("true");
}
});
});
// The owner's pick (2026-09-26): the compact row had a leading action column that sat
// empty at rest. Now the title leads, the "…" is always at the row's end, and on hover the quick
// toggles float in beside it over the date, which steps aside.
describe("the compact row (rowsCompact)", () => {
it("leads with the title and ends with the '…'; the toggles reveal beside it over a yielding date", () => {
renderCard("rowsCompact");
const row = screen.getByTestId("video-card");
expect(row.firstElementChild!.textContent).toBe("Cicada 3301");
const more = screen.getByRole("button", { name: "card.menu.more" });
// the layer that floats left of the "…" (right-full) and shows on hover only
const toggles = screen
.getByRole("button", { name: "card.markWatched" })
.closest<HTMLElement>(".right-full");
expect(toggles!.className).toContain("reveal-hover");
// the toggles and the "…" share the row's last slot (the resume bar is drawn after it)
const slot = more.parentElement!;
expect(slot.contains(toggles)).toBe(true);
const inFlow = [...row.children].filter((c) => !c.className.includes("absolute"));
expect(inFlow[inFlow.length - 1]).toBe(slot);
expect(row.querySelector(".w-48")!.className).toContain("group-hover:opacity-0");
});
});
// On a shelf (Home's strips) the card is one title line over the channel alone, so every card on
// the strip is the same height (the owner's shelf density).
describe("the card on a shelf", () => {
function renderSmall(shelf: boolean) {
render(
<QueryClientProvider client={new QueryClient()}>
<VideoCard
video={video}
view="cardsSmall"
width={0}
shelf={shelf}
onState={() => {}}
onToggleSave={() => {}}
/>
</QueryClientProvider>,
);
return screen.getByTestId("video-card");
}
const titleOf = (card: HTMLElement) =>
[...card.querySelectorAll("*")].find(
(e) => e.childElementCount === 0 && e.textContent === "Cicada 3301",
)!;
it("cuts the title to one line and drops the views · age line", () => {
const card = renderSmall(true);
const title = titleOf(card).closest(".font-medium")!;
expect(title.className).toContain("truncate");
expect(title.className).not.toContain("line-clamp-2");
expect(card.querySelector("time[title]")).toBeNull();
expect(screen.getByRole("link", { name: "LEMMiNO" })).toBeTruthy();
});
it("off a shelf keeps two title lines and the age", () => {
const card = renderSmall(false);
expect(titleOf(card).closest(".font-medium")!.className).toContain("line-clamp-2");
expect(card.querySelector("time[title]")).not.toBeNull();
});
});
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
//
// The video card's channel name was a <button>: a plain click opened the channel in-app, but
// middle-click and Ctrl/Cmd-click did nothing — while the title right above it, an anchor, honoured
// them. Found in the 2026-09 bug hunt, when the player's channel name had just become a
// real link and the card's had not. (The link's own gesture rules are pinned in
// ChannelNameLink.dom.test.tsx; this pins that the CARD uses it.)
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({ openChannel: vi.fn() }));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: h.openChannel }),
}));
import VideoCard from "./VideoCard";
import type { Video } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
const video = {
id: "abc123",
title: "Cicada 3301",
channel_id: CH,
channel_title: "LEMMiNO",
status: "new",
saved: false,
position_seconds: 0,
duration_seconds: 1200,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=abc123",
thumbnail_url: "",
} as unknown as Video;
beforeEach(() => {
cleanup();
h.openChannel.mockClear();
window.history.replaceState({}, "", "/?page=feed");
});
describe("VideoCard — the channel name", () => {
it("is a real link to the channel, and a plain click still opens it in-app", () => {
render(
<QueryClientProvider client={new QueryClient()}>
<VideoCard
video={video}
view="cards"
width={320}
onState={() => {}}
onToggleSave={() => {}}
/>
</QueryClientProvider>,
);
const link = screen.getByTestId("video-card-channel");
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
link.click();
expect(h.openChannel).toHaveBeenCalledWith(CH, "LEMMiNO");
});
});
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
//
// The video card's half of keyboard navigation (`lib/listNav`). Every layout's ROOT carries the
// list index — that is the element `j`/`k` focus and ring — and the title link is the card's `open`
// element, so Enter on a selected card runs the very click a mouse would: the in-app player.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
import VideoCard from "./VideoCard";
import type { Video } from "../../lib/api";
import type { FeedView } from "../../lib/feedView";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const video = {
id: "abc123",
title: "Cicada 3301",
channel_id: "UCsXVk37bltHxD1rDPwtNM8Q",
channel_title: "LEMMiNO",
status: "new",
saved: false,
position_seconds: 0,
duration_seconds: 1200,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=abc123",
thumbnail_url: "",
} as unknown as Video;
beforeEach(cleanup);
function renderCard(view: FeedView, navIndex?: number, onOpen = vi.fn()) {
render(
<QueryClientProvider client={new QueryClient()}>
<VideoCard
video={video}
view={view}
width={0}
navIndex={navIndex}
onState={() => {}}
onToggleSave={() => {}}
onOpen={onOpen}
/>
</QueryClientProvider>,
);
return onOpen;
}
// One per layout FAMILY: the stacked card, the thumbnailed row, the one-line row — three roots.
const VIEWS: FeedView[] = ["cards", "rows", "rowsCompact"];
describe("VideoCard — keyboard navigation hooks", () => {
for (const view of VIEWS) {
it(`puts the list index on the ${view} root and opens in-app from the title`, () => {
const onOpen = renderCard(view, 7);
const root = screen.getByTestId("video-card");
expect(root.getAttribute("data-nav-index")).toBe("7");
const open = root.querySelectorAll("[data-nav-open]");
expect(open).toHaveLength(1);
expect(open[0]!.textContent).toBe("Cicada 3301");
(open[0] as HTMLElement).click();
expect(onOpen).toHaveBeenCalledWith(video);
});
}
it("carries no index outside a navigable list (Home's rails)", () => {
renderCard("cards");
expect(screen.getByTestId("video-card").hasAttribute("data-nav-index")).toBe(false);
});
});
// The thumbnail link's only text is the duration badge, so each card's first Tab stop was
// announced as "20:00". It is named by the title, like the title link beside it.
describe("VideoCard — the thumbnail link", () => {
it("is named by the video's title, not its duration", () => {
renderCard("cards");
const links = screen.getAllByRole("link", { name: "Cicada 3301" });
expect(links.length).toBeGreaterThanOrEqual(2);
expect(screen.queryByRole("link", { name: "20:00" })).toBeNull();
});
});
// "Text stays selectable": the mouseup that ends selecting the title's words fires a click on
// the title; that click must not open the player (nor follow the link).
describe("VideoCard — a title you select", () => {
it("does not open when the click ends a selection inside it, and still opens on a plain click", () => {
const onOpen = renderCard("cards");
const title = screen.getByTestId("video-card").querySelector("[data-nav-open]") as HTMLElement;
// Both, or Chrome starts no selection on a link (measured in a real browser).
expect(title.getAttribute("draggable")).toBe("false");
expect(title.classList.contains("select-text")).toBe(true);
const range = document.createRange();
range.selectNodeContents(title);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
const click = new MouseEvent("click", {
bubbles: true,
cancelable: true,
button: 0,
detail: 1,
});
title.dispatchEvent(click);
expect(onOpen).not.toHaveBeenCalled();
expect(click.defaultPrevented).toBe(true);
window.getSelection()!.removeAllRanges();
title.click();
expect(onOpen).toHaveBeenCalledWith(video);
});
});
+727
View File
@@ -0,0 +1,727 @@
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigationActions } from "../navigation/navigationContext";
import {
Bookmark,
Check,
CheckCheck,
Clock,
Play,
Radio,
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
Video as VideoIcon,
} from "lucide-react";
import Avatar from "../../components/ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { navItemAttrs } from "../../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
import {
formatDate,
formatDuration,
formatNumber,
formatCompact,
relativeTime,
} from "../../lib/format";
import { isPlainLeftClick } from "../../lib/nav";
import { endsTextSelection } from "../../lib/textSelection";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "../../components/ui/IconButton";
import { SURFACE } from "../../components/ui/surfaces";
import { MoreButton } from "../../components/ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "../../components/ui/reveal";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaTitleClass,
} from "../../components/ui/mediaCardStyles";
import { MediaCard, MediaProgress } from "../../components/ui/MediaCard";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { RelativeTime } from "../../components/ui/RelativeTime";
import RemoteImage from "../../components/ui/RemoteImage";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
// The card keeps its state and the everyday toggles — watched, save, add to playlist — and
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
export function VideoHoverActions({
video,
onState,
onToggleSave,
more,
media = false,
dense = false,
}: {
video: Video;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
more?: More;
/** On the thumbnail: the round, dark `media` face (A2). */
media?: boolean;
/** Tighter gap for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const toggleWatched = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === "watched" ? "new" : "watched");
};
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
const variant = media ? "media" : "ghost";
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail; the
// one-line row floats three of them (no "…": its own stays put) over its date on hover.
return (
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
<IconButton
variant={variant}
onClick={toggleWatched}
pressed={video.status === "watched"}
label={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className="reveal-hover"
>
{video.status === "watched" ? <CheckCheck /> : <Check />}
</IconButton>
<IconButton
variant={variant}
onClick={toggleSave}
pressed={!!video.saved}
label={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className="reveal-hover"
>
<Bookmark className={clsx(video.saved && "fill-current")} />
</IconButton>
<span className="reveal-hover flex">
<AddToPlaylist videoId={video.id} variant={variant} />
</span>
{more && (
<MoreButton
variant={variant}
label={t("card.menu.more")}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
);
}
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp(
e: React.MouseEvent,
video: Video,
onOpen?: (v: Video, startAt?: number | null) => void,
): void {
if (!onOpen || !isPlainLeftClick(e)) return;
e.preventDefault();
// The click that ends selecting the title's words (to copy them) neither plays nor follows the
// link (docs/DESIGN.md "Text stays selectable").
if (endsTextSelection(e)) return;
onOpen(video);
}
// A non-zero saved position on an unfinished video = "in progress": offer Continue / Restart
// instead of a plain Play. Note this is TRUE even with no known duration — in which case there is
// no percentage to draw, so the two are deliberately separate predicates.
function isInProgress(video: Video): boolean {
return video.position_seconds > 0 && video.status !== "watched";
}
/** Resume position as 0-100, or 0 when there's nothing to draw. Shared by Thumb and the
* thumbnail-less row, which has no image to draw the bar on but still owes you the state. */
function resumePct(video: Video): number {
return isInProgress(video) && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
}
// Thumb overlays duration / live state / saved ON the image. The one-line row has no image, so it
// renders the same FACTS as columns of its own — deliberately not shared with Thumb's badges:
// floating chips over artwork and a table cell are different presentations, and forcing one
// component to do both would be a variant-flag knot for no reuse.
// The cost is real though: the rules below (duration, live/upcoming, was_live, saved) are ALSO in
// Thumb, so a change to them must land in both. If they ever do change, share the decision — a
// videoBadges(video) -> Badge[] — and keep only the markup separate.
//
// A fixed GRID, not a flex row: each datum has to hold its own x across rows or the column stops
// reading as a column. The flex row this replaces let a "36:36" and a "3:31:09" push the marker
// beside them to different places, and pulled it left again whenever the duration was absent
// (live/upcoming) — visible as a ragged edge down the page.
function RowMetaCells({ video }: { video: Video }) {
const { t } = useTranslation();
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
// in the row, so it gets about a letter's worth of width. Icons rather than a coloured dot
// because the three states must stay apart by SHAPE — in the youtube scheme --accent is red, so a
// "live" dot and a "stream" dot would be the same dot.
const status =
video.live_status === "live"
? // the broadcast red, not --danger: "live" is not an error
{ Icon: Radio, cls: "text-live", label: t("card.live") }
: video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live"
? { Icon: VideoIcon, cls: "text-accent", label: t("card.stream") }
: null;
const StatusIcon = status?.Icon;
// 4rem + 1rem + 1rem + two 4px gaps = 104px, which the one-line row's width tally counts on —
// widen a slot and the title column silently pays for it. rem, not px, so the whole cell tracks
// the text-size setting the same way its contents do.
return (
<div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
"1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
<span className="text-right tabular-nums">
{video.duration_seconds != null ? formatDuration(video.duration_seconds) : ""}
</span>
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
bare span has no role, and screen readers don't dependably surface title on one), while a
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
in VideoHoverActions — the label must not tell anyone to click something that doesn't respond. */}
<span>
{StatusIcon && (
<span role="img" aria-label={status.label} title={status.label}>
<StatusIcon className={clsx("w-3.5 h-3.5", status.cls)} />
</span>
)}
</span>
<span>
{video.saved && (
<span role="img" aria-label={t("card.savedMark")} title={t("card.savedMark")}>
<Bookmark className="w-3.5 h-3.5 text-accent fill-current" />
</span>
)}
</span>
</div>
);
}
function Thumb({
video,
className,
onOpen,
small = false,
bar,
}: {
video: Video;
className?: string;
onOpen?: (v: Video, startAt?: number | null) => void;
/** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
* Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
small?: boolean;
/** The card's hover action row, docked on the picture's bottom-right (the C1 reference). A
* SIBLING of the link, not inside it: a click in the playlists panel it opens would otherwise
* bubble (through the portal, along the React tree) into the link and start playback. */
bar?: ReactNode;
}) {
const { t } = useTranslation();
const inProgress = isInProgress(video);
const pct = resumePct(video);
// Open in the in-app player at an explicit position (null = resume from saved).
const open = (startAt: number | null) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpen?.(video, startAt);
};
return (
// min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio
// box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px
// tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`).
<div className={clsx("relative min-h-0", MEDIA_PICTURE_CONTAINER, className)}>
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
Tab stop of every card as "44:10". */}
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className={MEDIA_PICTURE}
>
{video.thumbnail_url ? (
<RemoteImage
src={video.thumbnail_url}
alt=""
loading="lazy"
decoding="async"
width={1280}
height={720}
className={MEDIA_IMG}
/>
) : (
<div className="w-full h-full" />
)}
{video.duration_seconds != null ? (
<span
className={clsx(
MEDIA_BADGE,
"bottom-1.5 right-1.5",
// The hover row takes this corner; the duration steps aside while it shows.
bar && HOVER_YIELD,
)}
>
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live" ? "bg-live text-live-fg" : "bg-black/80 text-white"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("card.stream")}
</span>
)}
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
reveal on hover AND on keyboard focus (else a Tab-focused button is invisible), and
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
{onOpen && (
<div className={MEDIA_HOVER_LAYER}>
{inProgress ? (
<MediaResumeButtons small={small} onContinue={open(null)} onRestart={open(0)} />
) : (
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
label={t("card.play")}
>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</div>
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
<MediaProgress pct={pct} />
</a>
{bar && <div className={MEDIA_BAR}>{bar}</div>}
</div>
);
}
// The one-line row's column widths at the DEFAULT text size, mirroring the Tailwind classes the
// boxes actually use. They have to be mirrored by hand — Tailwind can't take a runtime value, and
// the classes have to stay, because they're rem: Settings' text-size slider drives the root font
// (`html { font-size: calc(16px * var(--font-scale)) }`, 0.9–1.3), so the boxes must grow with
// their contents. Inline pixels froze them and a channel name at 130% wanted 152px in a 128px box.
// Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = {
more: 28, // the "…" at the row's end (size-7); the quick actions float over the date on hover
channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16
published: 192, // w-48
} as const;
const ROW_GAP = 12; // gap-3
const ROW_PAD = 26; // px-3 both sides, plus a rounding allowance
const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't worth showing
// What the row must measure before each column earns its place — cumulative, so each appears only
// once everything to its left AND a readable title already fit. Calibrated at the default text
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
const ROW_BASE = ROW_PAD + ROW_TITLE_MIN + ROW_GAP + ROW_COL.more;
const ROW_FITS = {
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
views: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta + ROW_GAP + ROW_COL.views,
published:
ROW_BASE +
ROW_GAP +
ROW_COL.channel +
ROW_GAP +
ROW_COL.meta +
ROW_GAP +
ROW_COL.views +
ROW_GAP +
ROW_COL.published,
};
function VideoCard({
video,
view,
width,
onState,
onToggleSave,
onOpen,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
shelf = false,
}: {
video: Video;
view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
/** Right-click / long-press props for the LIST's one context menu (the video's actions).
* A FUNCTION, not the already-built props object: `useContextMenu.triggerProps` is stable across
* renders while its return value is a fresh object every call, and a fresh object prop would
* blow past this component's memo for every card in the virtualized feed on every parent
* render. The card calls it with the video it already has. */
menuProps?: (v: Video) => Record<string, unknown>;
/** The same menu's "…" button props (`useContextMenu.buttonProps`) — a function, for the same memo
* reason. Omitted, the card has no "…" (the menu is still a right-click away). */
menuButtonProps?: (v: Video) => Record<string, unknown>;
/** This card's menu is the open one (the "…" button's `aria-expanded`). */
menuOpen?: boolean;
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
navIndex?: number;
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
shelf?: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { openChannel } = useNavigationActions();
const spec = FEED_VIEW_SPEC[view];
const watched = video.status === "watched";
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
// Thumb overlays on the image. Those are metadata, not decoration, so fold them into the meta
// line here rather than let the mode silently lose them.
const noThumb = !spec.thumb;
// Each datum on its own, so the one-line row can give each a column of its own while the stacked
// layouts still run them together as one sentence. The count comes in two shapes from one span —
// one span so the exact-count tooltip covers all of whatever is rendered: prose needs the word
// ("1.5K views · 2 min ago"), the row's column doesn't, because a table doesn't repeat its unit
// on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) =>
video.view_count != null && (
<span title={`${formatNumber(video.view_count, lang)} ${t("card.views")}`}>
{formatCompact(video.view_count, lang)}
{withWord && ` ${t("card.views")}`}
</span>
);
const published = (
<>
{relativeTime(video.published_at, lang, t)}
{video.published_at && (
<>
{" · "}
{formatDate(video.published_at, i18n.language)}
</>
)}
</>
);
// Show the full text in a native tooltip only when it's actually cut off — vertically where the
// title clamps to 2 lines, horizontally where a fixed column truncates it. Shared by the title
// and the channel name: both truncate in some views and fit in others, and a tooltip that just
// repeats what's already legible is noise.
const titleRef = useRef<HTMLAnchorElement>(null);
const channelRef = useRef<HTMLAnchorElement>(null);
const [titleClamped, setTitleClamped] = useState(false);
const [channelClamped, setChannelClamped] = useState(false);
useEffect(() => {
const cut = (el: HTMLElement) =>
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
const els: [HTMLElement | null, (v: boolean) => void][] = [
[titleRef.current, setTitleClamped],
[channelRef.current, setChannelClamped],
];
const check = () => els.forEach(([el, set]) => el && set(cut(el)));
check();
const ro = new ResizeObserver(check);
els.forEach(([el]) => el && ro.observe(el));
return () => ro.disconnect();
// `view` matters as much as the text: switching views returns a different branch, so React can
// remount these nodes — without it the observer would be left watching detached ones.
}, [video.title, video.channel_title, view]);
const title = (
<a
ref={titleRef}
href={video.watch_url}
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
// A link is dragged, not selected: Chrome starts no selection on one even with the drag off,
// until it is also `user-select: text` (select-text below) — measured in a real browser.
draggable={false}
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
// click: the in-app player, resuming where the reader left off.
data-nav-open=""
title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx(
// min-h-6: a one-line title is still a 24 target (B1).
"min-h-6 select-text hover:text-accent",
noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
)}
>
{video.title}
</a>
);
// A real link, like the title above it: a <button> here ignored middle-click and Ctrl/Cmd-click,
// so the same card honoured the browser's gestures on its title and silently dropped them on its
// channel name.
const channelLink = (
<ChannelNameLink
ref={channelRef}
data-testid="video-card-channel"
channelId={video.channel_id}
title={video.channel_title}
onOpenInApp={() =>
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"))
}
stopPropagation
tooltip={channelClamped ? (video.channel_title ?? undefined) : undefined}
// leading-6: the one-line name is a 24 target (B1), not its 20px line of text.
className="text-sm leading-6 text-muted truncate block w-fit max-w-full text-left hover:text-fg"
/>
);
const more = menuButtonProps && { props: menuButtonProps(video), open: menuOpen };
// The rows' action block: shown on hover / focus on a mouse (`.reveal`), always on touch — where
// the toggles inside it are gone and only the "…" is left.
const actions = (
<div className="reveal">
<VideoHoverActions
video={video}
onState={onState}
onToggleSave={onToggleSave}
more={more}
dense={spec.dense}
/>
</div>
);
// A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
// paint an opaque bg-card on hover — so running the pointer down the list kept covering and
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
// jumping as the pointer crosses it is a lot more restless than a card doing it.
const rowShell = `cv-row group ${SURFACE.card} glass-hover relative transition`;
if (spec.family === "row") {
// A table without being one. Every column is a FIXED width, which is what makes the same datum
// land on the same vertical line in every row. A grid can't align ACROSS rows here — VirtualFeed
// renders each as its own subtree, so there's no shared parent to be the grid — but a grid is
// still right WITHIN a cell (see RowMetaCells): fixed slots, so they line up across rows too.
// Widths are measured, not chosen by eye — against the worst case in the LIBRARY and in BOTH
// languages, since both have caught me out here:
// actions 188 (6 x 28 + 5 x 4) · meta cells 104 (see RowMetaCells) · views 48 (the bare
// "101.7K"; language-proof now the word is gone — with it, Hungarian's "megtekintés" needed
// 139) · published 183 ("11 months ago · Feb 28, 2026"; HU is shorter) · channel 128,
// which is a deliberate cut rather than a fit: names run to 342px, the median is 87, and 12
// of 102 truncate here vs 5 at 160 — worth 32px to the title, since a clipped channel name
// still reads while a clipped view count loses the number.
// Three ways this went wrong before: sizing off a PAGE of data (the badges came from 60 videos
// whose longest was 2:00:58, then wrapped on the 11-hour one); measuring with a hand-built probe
// instead of the real element (it read "101.7K megtekintés" as 122px where the live cell renders
// 139); and checking that the COLUMNS aligned without checking the data inside them (the meta
// cell was a flex row, so its contents slid about — see RowMetaCells).
// The title takes what's left, which is constant per container width — so it aligns too.
if (noThumb) {
const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs;
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* The title leads (the owner's pick, 2026-09-26): the actions used to lead in a
128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's
end holds a faint "…" (always there: the menu's door on every device), and on hover or
keyboard focus the quick toggles float in beside it OVER the date, which steps aside —
the Gmail row. On touch the toggles are gone (reveal-hover) and the "…" stays.
Everything but the title is shrink-0, so the title is the only thing that can give —
which means once the fixed columns outgrow the row, it gives ALL of it and vanishes.
Columns drop from the right as the row narrows, least important first: a title with no
date beats a date with no title. `width` is the ROW's own, so this holds whatever the
rail and the filter panel are doing. Until the first measure lands (width 0) show
everything: the row is off-screen for that frame anyway, and guessing narrow would
flash. */}
<div className="min-w-0 flex-1">{title}</div>
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
{fits(ROW_FITS.meta) && (
<div className={clsx(!fits(ROW_FITS.views) && HOVER_YIELD)}>
<RowMetaCells video={video} />
</div>
)}
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
took this from 144 and handed the difference to the title. */}
{fits(ROW_FITS.views) && (
<div
className={clsx(
"shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate",
!fits(ROW_FITS.published) && HOVER_YIELD,
)}
>
{viewsCell(false)}
</div>
)}
{fits(ROW_FITS.published) && (
<div className={clsx("shrink-0 w-48 text-sm text-muted truncate", HOVER_YIELD)}>
{published}
</div>
)}
<div className="relative shrink-0">
<div
className={clsx(HOVER_REVEAL, "absolute inset-y-0 right-full mr-1 flex items-center")}
>
<VideoHoverActions video={video} onState={onState} onToggleSave={onToggleSave} />
</div>
{more && (
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
)}
</div>
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
);
}
}
// Everything below is a STACKED layout — the card and the thumbnailed row. Only they run the
// data together as one sentence; the one-line row above gives each datum its own column and has
// already returned, so building this for it would be waste dressed up as shared code.
const views = viewsCell(true);
const textBlock = (
<>
{title}
<div className="mt-0.5">{channelLink}</div>
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{published}
</div>
</>
);
if (spec.family === "row") {
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
>
<Thumb
video={video}
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
onOpen={onOpen}
small
/>
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
reserves — which frees the ~168px the docked-right block used, so the block still
works in a narrow column. mt-auto pins them to the tile's bottom edge (the flex row
already stretches this column to full height), so they land in the same place
whether the title above them ran to one line or three. */}
{spec.actionsBelow && <div className="mt-auto pt-1">{actions}</div>}
</div>
{!spec.actionsBelow && actions}
</div>
);
}
// The card's action row sits on its picture (the C1 reference: shown on hover, never taking a line
// of its own), and on touch its "…" sits beside the title as a 44px target. The frame, the pinned
// meta line and the picture's pieces are the one media card's (ui/MediaCard), shared with Plex.
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
// by a pixel or two forever, which shows up as the whole page juddering. The grid stretches the
// card to its row on its own, which is what the pinned meta line needs.
return (
<MediaCard
rootProps={{
"data-testid": "video-card",
"data-video-id": video.id,
...menuProps?.(video),
...(navIndex !== undefined && navItemAttrs(navIndex)),
}}
className="cv-card"
dense={spec.dense}
dim={watched}
// The small card's image is in the SAME size class as a row's (its column bottoms out at
// 180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
// and would spill straight out of it.
picture={
<Thumb
video={video}
className="aspect-video"
onOpen={onOpen}
small={spec.dense}
bar={
<VideoHoverActions
video={video}
onState={onState}
onToggleSave={onToggleSave}
more={more}
media
/>
}
/>
}
// The small card drops the channel avatar: at its ~180px column the 36px avatar plus the gap
// would leave the title barely half the width. The channel name is still in the meta line.
leading={
!spec.dense && (
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)
}
title={title}
meta={
shelf ? (
channelLink
) : (
<>
{channelLink}
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{/* The small card keeps the relative time and moves the date into its tooltip: at a
shelf's width the full pair wrapped to a third meta line, and the tallest card sets
the whole shelf's height (every card fills its slot). */}
{spec.dense ? <RelativeTime at={video.published_at} /> : published}
</div>
</>
)
}
more={more || undefined}
moreLabel={t("card.menu.more")}
/>
);
}
// Memoized so appending a feed page only renders the new cards, not every existing
// one (the callbacks from Feed are stable; non-overridden video objects keep their
// identity across renders).
export default memo(VideoCard);
@@ -0,0 +1,35 @@
import { lazy, Suspense } from "react";
import { type Video } from "../../lib/api";
import { AddToPlaylistAt } from "./AddToPlaylist";
import VideoDetailsAt from "./VideoInfoCard";
// Lazy for the same reason DownloadButton's is: the dialog and its profile editor load only when
// someone downloads, not with every feed.
const DownloadDialog = lazy(() => import("../downloads/DownloadDialog"));
/** What a video menu row opened that outlives the menu: a panel anchored where the menu came from
* (the playlists, the details) or the download dialog. */
export type VideoFollowUpState =
| { kind: "playlist" | "details"; video: Video; anchor: Element }
| { kind: "download"; video: Video };
/** The one follow-up a video list has open, mounted once per list beside its menu (see
* `useVideoActions`) rather than once per card. */
export function VideoFollowUp({
state,
onClose,
}: {
state: VideoFollowUpState;
onClose: () => void;
}) {
const { video } = state;
if (state.kind === "download")
return (
<Suspense fallback={null}>
<DownloadDialog source={video.id} title={video.title} onClose={onClose} />
</Suspense>
);
if (state.kind === "playlist")
return <AddToPlaylistAt videoId={video.id} anchor={state.anchor} onClose={onClose} />;
return <VideoDetailsAt video={video} anchor={state.anchor} onClose={onClose} />;
}
@@ -0,0 +1,127 @@
// @vitest-environment jsdom
//
// Contract of a video's details panel (opened from the video's menu): the
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui, so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const videoDetail = vi.fn();
const channelDetail = vi.fn();
const tags = vi.fn();
vi.mock("../../lib/api", () => ({
api: {
videoDetail: (...a: unknown[]) => videoDetail(...a),
channelDetail: (...a: unknown[]) => channelDetail(...a),
tags: (...a: unknown[]) => tags(...a),
},
}));
import VideoDetailsAt from "./VideoInfoCard";
import type { Video } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const video = (over: Partial<Video> = {}): Video =>
({
id: "v1",
title: "The full untruncated title",
channel_id: "c1",
channel_title: "Test Channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/c1",
published_at: "2026-02-24T00:00:00Z",
thumbnail_url: null,
duration_seconds: 754,
view_count: 1512,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 0,
saved: false,
watch_url: "https://youtu.be/v1",
...over,
}) as Video;
const open = (v: Video = video(), onClose = vi.fn()) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const anchor = document.createElement("div");
document.body.appendChild(anchor);
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoDetailsAt video={v} anchor={anchor} onClose={onClose} />
</FloatingTree>
</QueryClientProvider>,
);
return { onClose };
};
describe("VideoDetailsAt", () => {
// Escape is measured in the browser: jsdom has no layout, so the anchor reads as hidden and the
// layer stack never takes the panel's Escape.
it("is a named dialog", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: null });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
tags.mockResolvedValue([]);
open();
await screen.findByText("The full untruncated title");
// Named by its aria-label (the role query cannot see into the unpositioned, hidden surface).
expect(document.querySelector("[role=dialog]")?.getAttribute("aria-label")).toBe("card.info");
});
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: "A longer description snippet." });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [1, 3] });
tags.mockResolvedValue([
{ id: 1, name: "English", category: "language" },
{ id: 2, name: "Unused", category: "topic" },
{ id: 3, name: "Astronomy", category: "topic" },
]);
open();
// Header is from the Video prop — present without waiting on any query.
expect(screen.getByText("The full untruncated title")).toBeTruthy();
expect(screen.getByText("Test Channel")).toBeTruthy();
// Description + only the channel's own tags (1 + 3, not 2) once the queries resolve.
expect(await screen.findByText("A longer description snippet.")).toBeTruthy();
expect(await screen.findByText("English")).toBeTruthy();
expect(await screen.findByText("Astronomy")).toBeTruthy();
expect(screen.queryByText("Unused")).toBeNull();
});
it("falls back to the no-description line when the video has none", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: null });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
tags.mockResolvedValue([]);
open();
expect(await screen.findByText("card.noDescription")).toBeTruthy();
});
});
@@ -0,0 +1,114 @@
import { useLayoutEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { api, type Video } from "../../lib/api";
import { formatDate, formatDuration, formatCompact } from "../../lib/format";
import { linkify } from "../../lib/linkify";
import Avatar from "../../components/ui/Avatar";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { SURFACE } from "../../components/ui/surfaces";
import { Badge } from "../../components/ui/Badge";
import { useTags } from "../../lib/useTags";
// A video's details: full (un-clamped) title, channel, views·duration·date, a
// description snippet, and the channel's tags. It moved from a hover ⓘ on the thumbnail into
// the video's action list ("Details"): the hover card was invisible on touch and one more button in
// the card's hover row, and the menu is the one door every device has. It opens anchored to the card
// the menu came from, as a small dialog.
//
// The card face already carries title/channel/date/duration/views, so the box's real added value is
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
// list payload. Both are fetched ON DEMAND on open (like AboutCell): the description via
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
// channel page's cache) against the tag list. Until they land the header still shows from the Video
// we already have, so the box is never empty.
export default function VideoDetailsAt({
video,
anchor,
onClose,
}: {
video: Video;
anchor: Element;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const pop = useAnchoredPopover({
open: true,
onOpenChange: (next) => {
if (!next) onClose();
},
mode: "dialog",
role: "dialog",
placement: "bottom-start",
offset: 6,
fitHeight: true,
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
const { data: detail } = useQuery({
queryKey: qk.videoDetail(video.id),
queryFn: () => api.videoDetail(video.id),
staleTime: 5 * 60_000,
});
const { data: channel } = useQuery({
queryKey: qk.channel(video.channel_id),
queryFn: () => api.channelDetail(video.channel_id),
enabled: !!video.channel_id,
staleTime: 5 * 60_000,
});
const { data: tags } = useTags({ staleTime: 5 * 60_000 });
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
const tagNames =
channel && tags
? channel.tag_ids
.map((id) => tags.find((tg) => tg.id === id))
.filter((tg): tg is NonNullable<typeof tg> => !!tg)
: [];
const description = detail?.description?.trim();
const metaBits = [
video.view_count != null ? `${formatCompact(video.view_count, lang)} ${t("card.views")}` : null,
video.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
video.published_at ? formatDate(video.published_at, i18n.language) : null,
].filter(Boolean);
return (
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
className={`${SURFACE.popover} w-85 max-w-[92vw] overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
>
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
<div className="mt-2 flex items-center gap-2">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-6 h-6 rounded-full shrink-0"
/>
<span className="text-xs text-muted truncate">{video.channel_title}</span>
</div>
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
{description ? (
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
{linkify(description)}
</p>
) : detail ? (
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
) : null}
{tagNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tagNames.map((tg) => (
<Badge key={tg.id}>{tg.name}</Badge>
))}
</div>
)}
</PopoverSurface>
);
}
@@ -0,0 +1,67 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import videoCard from "./VideoCard.tsx?raw";
import { HOVER_REVEAL, HOVER_YIELD } from "../../components/ui/reveal";
import {
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER,
MEDIA_RESUME_LAYER,
mediaPictureBox,
} from "../../components/ui/mediaCardStyles";
// Measured: at text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the 37px hover
// row (the Play and the row grow with the root font, the picture's height does not). A narrow picture
// (under 14rem, so the cut moves with the text size) centres what it carries above the row instead.
describe("a media picture", () => {
it("is a size container, so what sits on it answers its width", () => {
expect(mediaPictureBox("wide")).toContain(MEDIA_PICTURE_CONTAINER);
expect(mediaPictureBox("poster")).toContain(MEDIA_PICTURE_CONTAINER);
// The video card draws its own box (it is not a mediaPictureBox).
expect(videoCard).toContain('clsx("relative min-h-0", MEDIA_PICTURE_CONTAINER, className)');
});
it.each([
["the Play", MEDIA_HOVER_LAYER],
["the Continue + Restart pair", MEDIA_RESUME_LAYER],
])("leaves the hover row its band when narrow: %s", (_, layer) => {
expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-(--bar-room)");
});
// The lift once applied to every narrow picture, and the feed's row and tile views (a
// 10rem thumbnail, no hover row) showed their Play 1.25rem above centre over an empty band. The band
// is now the row's own: set only by a box that holds one.
it("lifts it only where a hover row is there to clear", () => {
expect(MEDIA_BAR.split(" ")).toContain("media-bar");
expect(MEDIA_PICTURE_CONTAINER.split(" ")).toContain("has-[.media-bar]:[--bar-room:2.5rem]");
for (const layer of [MEDIA_HOVER_LAYER, MEDIA_RESUME_LAYER])
expect(layer).not.toMatch(/pic:pb-\d/);
});
});
const indexCss = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
// Back from a channel page hands the card its focus again, after a mouse trip: with `focus-within`
// its hover row stayed up under no pointer until a reload (owner). The chrome answers the pointer and
// KEYBOARD focus only (`:focus-visible` on the card or inside it); jsdom cannot evaluate either, so
// the classes are the contract, and the global `.reveal` rule says the same.
describe("a card's hover chrome", () => {
it.each([
["the hover row", HOVER_REVEAL],
["what yields to it", HOVER_YIELD],
["the Play layer", MEDIA_HOVER_LAYER],
["the Continue + Restart layer", MEDIA_RESUME_LAYER],
])("shows on keyboard focus, never on a focus handed back: %s", (_, cls) => {
const parts = cls.split(" ");
expect(parts.some((c) => c.startsWith("group-focus-within:"))).toBe(false);
expect(parts.some((c) => c.startsWith("group-focus-visible:"))).toBe(true);
expect(parts.some((c) => c.startsWith("group-has-focus-visible:"))).toBe(true);
});
it("is the same in the global .reveal rule", () => {
expect(indexCss).not.toMatch(/\.group:focus-within \.reveal/);
expect(indexCss).toMatch(/\.group:has\(:focus-visible\) \.reveal/);
});
});
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
//
// The video menu's download row was labelled with the download's STATE ("Downloaded",
// "In queue") — a row that reads as a status yet opens the download dialog. It is "Download…" now,
// the state riding along as the row's hint.
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
vi.mock("../comfort/comfortPin", () => ({
pinVideo: vi.fn(),
useComfortPin: () => ({ item: () => [] }),
}));
import { api, type Video } from "../../lib/api";
import { useVideoActions } from "./useVideoActions";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const video = { id: "v1", title: "A video", status: "new", position_seconds: 0 } as Video;
function downloadRow(state: string | undefined) {
vi.spyOn(api, "downloadIndex").mockResolvedValue(
(state ? { v1: state } : {}) as Awaited<ReturnType<typeof api.downloadIndex>>,
);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const { result } = renderHook(
() => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn() }, true),
{ wrapper },
);
return () => result.current.items(video, null).find((i) => i.id === "download")!;
}
describe("the video menu's download row", () => {
// The state rode in a `title`, which a key or a finger never shows.
it("says what it does, whatever the state, and carries the state as its note", async () => {
const done = downloadRow("done");
await waitFor(() => expect(done().note).toBe("downloads.button.downloaded"));
expect(done().label).toBe("downloads.menu.download");
cleanup();
const none = downloadRow(undefined);
await waitFor(() => expect(none().label).toBe("downloads.menu.download"));
expect(none().note).toBeUndefined();
});
});
// The feed's channel filter from a card (owner): under "Open the channel", a row that adds the card's
// channel to the filter, or takes it out again when it is already in.
describe("the video menu's channel-filter row", () => {
const menu = (ids: string[] | null) => {
vi.spyOn(api, "downloadIndex").mockResolvedValue({});
const set = vi.fn();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const { result } = renderHook(
() =>
useVideoActions(
{
onState: vi.fn(),
onToggleSave: vi.fn(),
channelFilter: ids ? { ids, set } : undefined,
},
true,
),
{ wrapper },
);
const items = result.current.items({ ...video, channel_id: "UCa" }, null);
return { items, set };
};
it("adds the channel to a filter that names others, right under Open the channel", () => {
const { items, set } = menu(["UCz"]);
const at = items.findIndex((i) => i.id === "channel");
const row = items[at + 1]!;
expect(row.id).toBe("channel-filter");
expect(row.label).toBe("card.menu.channelFilterAdd");
row.onSelect?.();
expect(set).toHaveBeenCalledWith(["UCz", "UCa"]);
});
it("takes it out again when it is in", () => {
const { items, set } = menu(["UCz", "UCa"]);
const row = items.find((i) => i.id === "channel-filter")!;
expect(row.label).toBe("card.menu.channelFilterRemove");
row.onSelect?.();
expect(set).toHaveBeenCalledWith(["UCz"]);
});
it("is absent where the host gives no filter (a channel page, Home, a live search)", () => {
expect(menu(null).items.some((i) => i.id === "channel-filter")).toBe(false);
});
});
@@ -0,0 +1,207 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
Bookmark,
BookmarkX,
Check,
Download,
Eye,
EyeOff,
Filter,
FilterX,
Info,
ListPlus,
Play,
RotateCcw,
SkipBack,
Tv,
Undo2,
} from "lucide-react";
import { api, type Video, type VideoStatus } from "../../lib/api";
import { isResettable } from "../../lib/api/feed";
import { qk } from "../../lib/queryKeys";
import { pinVideo, useComfortPin } from "../comfort/comfortPin";
import { useCopyItems } from "../../components/ui/copyItems";
import { useNavigationActions } from "../navigation/navigationContext";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
// and the Home shelves list the same actions in the same order. The card itself keeps only its
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
// download, details, copy, hide, reset, pin — lives here, and only here.
export interface VideoActionHandlers {
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Omitted where a surface cannot reset a video's state (Home's shelves). */
onResetState?: (id: string) => void;
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
onOpen?: (v: Video, startAt?: number | null) => void;
/** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
* page (the view is that channel already), in a live YouTube search and on Home. */
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
}
const icon = "w-4 h-4";
/** `open` is the host menu's own flag: the download state and the shelf are fetched only once a
* menu has been opened, since they only label a row. Returns the item builder and the follow-up
* panel to render beside the menu (one per list). */
export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
const { t } = useTranslation();
const { openChannel } = useNavigationActions();
const pin = useComfortPin(open);
const copy = useCopyItems();
const index = useQuery({
queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex,
enabled: open,
staleTime: 10000,
});
const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(null);
/** `pin: false` where the host menu carries its own shelf rows (Home's rails). */
const items = (
v: Video,
anchor: Element | null,
{ pin: withPin = true }: { pin?: boolean } = {},
): ActionMenuItem[] => {
const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
const watched = v.status === "watched";
const hidden = v.status === "hidden";
const inProgress = v.position_seconds > 0 && !watched;
const dl = index.data?.[v.id];
// The row opens the download dialog, so it says so; the download's state is the line under its
// label — "Downloaded" read as a status, not as a row that opens something — and
// not a `title`, which a key or a finger never shows.
const dlState =
dl === "done"
? t("downloads.button.downloaded")
: dl === "queued" || dl === "running" || dl === "paused"
? t("downloads.button.queued")
: undefined;
// The two panels open beside the object; a menu opened with no element to anchor to (none of
// the triggers does that today) leaves them out rather than opening them at the corner.
const at = (kind: "playlist" | "details") =>
anchor ? () => setFollowUp({ kind, video: v, anchor }) : null;
const addTo = at("playlist");
const details = at("details");
return [
...(onOpen
? [
{
id: "play",
label: inProgress ? t("card.continue") : t("card.play"),
icon: <Play className={icon} />,
onSelect: () => onOpen(v, null),
},
...(inProgress
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className={icon} />,
onSelect: () => onOpen(v, 0),
},
]
: []),
]
: []),
{
id: "channel",
label: t("card.menu.openChannel"),
icon: <Tv className={icon} />,
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
},
// The channel's videos in this feed, or the channel out of the filter again: channels OR
// together, so adding one widens a filter that already names others (as "Show in feed" does).
...(channelFilter
? [
channelFilter.ids.includes(v.channel_id)
? {
id: "channel-filter",
label: t("card.menu.channelFilterRemove"),
icon: <FilterX className={icon} />,
onSelect: () =>
channelFilter.set(channelFilter.ids.filter((id) => id !== v.channel_id)),
}
: {
id: "channel-filter",
label: t("card.menu.channelFilterAdd"),
icon: <Filter className={icon} />,
onSelect: () => channelFilter.set([...channelFilter.ids, v.channel_id]),
},
]
: []),
{
id: "watched",
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
separatorBefore: true,
onSelect: () => onState(v.id, watched ? "new" : "watched"),
},
{
id: "save",
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
onSelect: () => onToggleSave(v.id, !v.saved),
},
...(addTo
? [
{
id: "playlist",
label: t("playlists.addToPlaylist"),
icon: <ListPlus className={icon} />,
onSelect: addTo,
},
]
: []),
{
id: "download",
label: t("downloads.menu.download"),
note: dlState,
icon: <Download className={icon} />,
onSelect: () => setFollowUp({ kind: "download", video: v }),
},
...(details
? [
{
id: "details",
label: t("card.info"),
icon: <Info className={icon} />,
separatorBefore: true,
onSelect: details,
},
]
: []),
...copy(v.title, v.watch_url).map((it, i) =>
i === 0 && !details ? { ...it, separatorBefore: true } : it,
),
{
id: "hide",
label: hidden ? t("card.unhide") : t("card.hide"),
icon: hidden ? <Eye className={icon} /> : <EyeOff className={icon} />,
separatorBefore: true,
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
},
...(onResetState && isResettable(v)
? [
{
id: "reset",
label: t("card.resetState"),
icon: <RotateCcw className={icon} />,
onSelect: () => onResetState(v.id),
},
]
: []),
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
];
};
const panel = followUp && <VideoFollowUp state={followUp} onClose={() => setFollowUp(null)} />;
return { items, panel };
}
@@ -0,0 +1,145 @@
// @vitest-environment jsdom
//
// The wizard is a modal overlay in every way that matters to a user, but it renders its own shell
// instead of going through `ui/Modal` — and for a keyboard or screen-reader user it had nothing of
// the dialog contract: no role, no label, no initial focus, no trap, no restore. Tab past its last
// control walked into the app behind the scrim, where the focus ring is invisible.
//
// `focusablesIn` filters on `el.offsetParent !== null` and jsdom reports null for everything, so the
// trap is untestable as written without the same stub `ui/Modal.dom.test.tsx` uses.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
};
});
vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.fn() }));
import OnboardingWizard from "./OnboardingWizard";
import { api, type Me } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications";
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
let origDesc: PropertyDescriptor | undefined;
beforeEach(() => {
vi.spyOn(api, "myStatus").mockResolvedValue({ channels_total: 0 } as never);
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
configurable: true,
get(this: HTMLElement) {
return this.isConnected ? document.body : null;
},
});
});
afterEach(() => {
cleanup();
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
else delete (HTMLElement.prototype as unknown as Record<string, unknown>).offsetParent;
vi.restoreAllMocks();
});
const mount = () =>
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<OnboardingWizard me={me} onClose={() => {}} />
</QueryClientProvider>,
);
describe("the onboarding wizard as a dialog", () => {
it("announces itself as a labelled modal dialog", () => {
mount();
const panel = screen.getByRole("dialog");
expect(panel.getAttribute("aria-modal")).toBe("true");
// The label is the step's own heading — the wizard has no header row to take a title from.
const heading = document.getElementById(panel.getAttribute("aria-labelledby")!);
expect(heading?.textContent).toBe("onboarding.read.title");
});
it("opens focused inside itself and keeps Tab from leaving", () => {
const outside = document.createElement("button");
outside.textContent = "behind the scrim";
document.body.appendChild(outside);
outside.focus();
const { unmount } = mount();
const panel = screen.getByRole("dialog");
expect(panel.contains(document.activeElement)).toBe(true);
// From the last control, Tab wraps back to the first instead of reaching the page behind.
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
stops[stops.length - 1]!.focus();
fireEvent.keyDown(window, { key: "Tab" });
expect(document.activeElement).toBe(stops[0]);
// …and closing hands the focus back to whatever opened it.
unmount();
expect(document.activeElement).toBe(outside);
outside.remove();
});
it("holds a Shift+Tab that starts on the panel itself", () => {
// `Node.contains(self)` is true, so focus sitting ON the tabIndex=-1 panel — where a click on
// its dead space puts it — matched no branch of `trapTab` and the browser's native Shift+Tab
// ran, into the app behind the scrim. That is what the third argument exists for, and this
// caller was written without it on the very commit that extracted the helper.
mount();
const panel = screen.getByRole("dialog");
panel.focus();
expect(document.activeElement).toBe(panel);
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
fireEvent.keyDown(window, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(stops[stops.length - 1]);
});
it("still hands the focus back after it visited the wizard's own toast", () => {
// Ownership has to be measured over the same scope the Tab trap uses. The wizard's toast host
// sits beside the panel and INSIDE the dialog, so tabbing to a toast's ✕ is legal — but read
// against the panel alone it said "the user left", and since removing the toast fires no
// focusin to re-arm, closing afterwards stranded the focus on <body>. Unlike
// `ui/Modal`, whose root carries the aria-modal attribute, nothing else can rescue this one.
const outside = document.createElement("button");
document.body.appendChild(outside);
outside.focus();
const { unmount } = mount();
act(() => void notify({ level: "error", message: "offline" }));
const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus();
expect(document.activeElement).toBe(dismissToast);
act(() => clearAll()); // the toast goes away under the focus — no focusin follows
unmount();
expect(document.activeElement).toBe(outside);
outside.remove();
});
it("keeps its own toast's dismiss button inside the Tab cycle", () => {
// The wizard renders its own `<Toaster overlay>` beside the panel, and the connection notice it
// can raise never auto-dismisses — so a trap scoped to the panel alone left that ✕ permanently
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
mount();
const panel = screen.getByRole("dialog");
act(() => void notify({ level: "error", message: "offline" }));
try {
const dismissToast = screen.getByTitle("notifications.dismiss");
expect(panel.contains(dismissToast)).toBe(false);
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
stops[stops.length - 1]!.focus();
fireEvent.keyDown(window, { key: "Tab" });
// No longer the edge: Tab carries on (natively) towards the toast instead of wrapping.
expect(document.activeElement).toBe(stops[stops.length - 1]);
dismissToast.focus();
fireEvent.keyDown(window, { key: "Tab" });
expect(document.activeElement).toBe(stops[0]);
} finally {
act(() => clearAll());
}
});
});
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
//
// Escape closes the topmost layer only — the wizard included. Its own Escape
// was a raw `document` listener with no layer check, and document listeners run before the window
// listener ui/Modal uses: a sync that failed inside the wizard opens the global error dialog, and one
// Esc dismissed the error AND ended onboarding, skipping the write-scope step.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
};
});
vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.fn() }));
import OnboardingWizard from "./OnboardingWizard";
import Modal from "../../components/ui/Modal";
import { api, type Me } from "../../lib/api";
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
beforeEach(() => {
vi.spyOn(api, "myStatus").mockResolvedValue({ channels_total: 0 } as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the onboarding wizard's Escape", () => {
const mount = (onClose: (dismiss: boolean) => void, dialog: boolean) =>
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<OnboardingWizard me={me} onClose={onClose} />
{dialog && (
<Modal initialFocus="close" title="boom" onClose={() => {}}>
<p>the sync failed</p>
</Modal>
)}
</QueryClientProvider>,
);
it("closes the wizard when nothing is open above it", () => {
const onClose = vi.fn();
mount(onClose, false);
fireEvent.keyDown(document, { key: "Escape" });
// Read was never granted: leaving now stops the wizard opening by itself.
expect(onClose).toHaveBeenCalledExactlyOnceWith(true);
});
it("leaves the wizard alone while a dialog is open over it", () => {
const onClose = vi.fn();
mount(onClose, true);
fireEvent.keyDown(screen.getByText("the sync failed"), { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,328 @@
import { useEffect, useId, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Me } from "../../lib/api";
import { beginGrant, endOnboarding } from "../../lib/onboarding";
import { Button } from "../../components/ui/Button";
import Overlay from "../../components/ui/Overlay";
import Toaster from "../../components/ui/Toaster";
import { useOverlayOrder } from "../../components/ui/overlayOrder";
import { useLayer } from "../../lib/layerStack";
import { trapTab, useDialogChrome } from "../../components/ui/dialogFocus";
import { IconButton } from "../../components/ui/IconButton";
import { SURFACE } from "../../components/ui/surfaces";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
// app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is
// always an explicit, informed opt-in here.
//
// The visible step is derived from what's already granted, so the wizard resumes correctly
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
// Right after read is granted, it auto-imports the user's subscriptions so they land on a
// populated feed (channels already in the shared catalog appear instantly).
// A small reusable heads-up so the Google consent warning never feels like a surprise.
function ConsentHeadsUp() {
return (
<div className="flex gap-2.5 rounded-xl border border-warn/30 bg-warn/5 p-3 text-left">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-warn" />
<p className="text-xs leading-relaxed text-fg/80">
<Trans
i18nKey="onboarding.consent"
components={[
<span className="font-medium" />,
<span className="font-medium" />,
<span className="font-medium" />,
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
/>,
]}
/>
</p>
</div>
);
}
function Dots({ index, total }: { index: number; total: number }) {
return (
<div className="flex justify-center gap-1.5">
{Array.from({ length: total }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
}`}
/>
))}
</div>
);
}
export default function OnboardingWizard({
me,
onClose,
}: {
me: Me;
/** `dismiss`: the essential read step was skipped, so the wizard stops opening by itself. */
onClose: (dismiss: boolean) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const importTriggered = useRef(false);
// Once we have read access, find out whether any subscriptions are imported yet.
const myStatus = useQuery({
queryKey: qk.myStatus(),
queryFn: api.myStatus,
enabled: me.can_read,
});
const importSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: () => {
// Feed/channels/status now have content — refresh the app behind the wizard.
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
},
// The step itself says the import failed and offers a retry button — no toast on top.
meta: { errorShownInline: true },
});
const { mutate: startImport } = importSubs;
// First time we see read access with zero imported channels, pull the user's
// subscriptions automatically so they don't have to hunt for a button.
useEffect(() => {
if (
me.can_read &&
myStatus.data &&
myStatus.data.channels_total === 0 &&
!importTriggered.current
) {
importTriggered.current = true;
startImport();
}
// `mutate` is stable (react-query memoises it) while the mutation object around it is not, so
// the effect can name what it calls. The ref keeps it to once regardless.
}, [me.can_read, myStatus.data, startImport]);
// Closing without finishing the essential read step suppresses future auto-popups
// (it stays reachable from Settings). Once read is granted, closing is just "done".
const close = () => {
endOnboarding();
onClose(!me.can_read);
};
const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster)
// A layer in the shared stack, so Escape closes the wizard only when it is on top: a raw document
// listener ran BEFORE the error dialog's (a sync that failed inside the wizard opens one), and one
// Esc closed the error AND ended onboarding, skipping the write-scope step.
const layer = useLayer(true, close);
// The rest of the dialog contract. This is a modal overlay in every way that matters to a user, but
// it renders its own shell (it is not a titled dialog with a header row), so Escape ordering was all
// it had: focus started on <body>, nothing announced a dialog, and Tab past the last control walked
// into the app behind the scrim — where the focus ring is invisible. The trap
// and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy.
const panelRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useDialogChrome(panelRef, rootRef);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Only while nothing that owns the keyboard is above us: an error dialog opened from inside the
// wizard owns the Tab, a hover card under the pointer does not.
if (e.key !== "Tab" || layer.blockingAbove()) return;
const panel = panelRef.current;
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
// neither of them (on the commit that extracted the helper so the two shells could
// not disagree). Scope is the whole dialog, so the toast host beside the panel stays in the
// Tab cycle (a sticky connection notice's ✕ was unreachable from the keyboard); `container`
// is the tabIndex=-1 panel, because `panel.contains(panel)` is true, so focus sitting ON the
// panel — where a click on its dead space puts it — matched no branch at all and a Shift+Tab
// walked out into the app behind the scrim.
if (panel) trapTab(e, rootRef.current ?? panel, panel);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [layer]);
// Are we currently importing (or about to)? Show a progress screen instead of the
// "you're connected" step until the first import settles. Once triggered, the mutation itself says
// which (pending / success / error) — the trigger ref is the effect's once-only latch, never read
// here (a ref read in render made the React Compiler skip the wizard).
const importing =
me.can_read &&
!importSubs.isError &&
(importSubs.isPending || (myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
return (
<Overlay>
<div ref={rootRef} className="fixed inset-0 z-overlay grid place-items-center p-4">
<div
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
onClick={close}
/>
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`${SURFACE.panel} relative w-[min(94vw,460px)] p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]`}
>
<IconButton
size="md"
onClick={close}
className="absolute top-3 right-3"
label={t("onboarding.close")}
>
<X />
</IconButton>
{step === "read" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<YouTubeIcon className="w-6 h-6" />
</div>
<h2 id={titleId} className="text-lg font-semibold">
{t("onboarding.read.title")}
</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
<Trans
i18nKey="onboarding.read.body"
components={[<span className="text-fg/90" />]}
/>
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<Button size="lg" icon={<YouTubeIcon />} onClick={() => beginGrant("read")}>
{t("onboarding.read.connect")}
</Button>
<Button variant="ghost" size="md" onClick={close}>
{t("onboarding.read.skip")}
</Button>
</div>
</>
)}
{/* Read granted: show import progress before the optional write step. */}
{me.can_read && importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<h2 id={titleId} className="text-lg font-semibold">
{t("onboarding.importing.title")}
</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
{t("onboarding.importing.body")}
</p>
</>
)}
{step === "write" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 id={titleId} className="text-lg font-semibold">
{t("onboarding.write.title")}
</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
{importSubs.isError ? (
<>{t("onboarding.write.importFailed")}</>
) : (
<>
{t("onboarding.write.feedReady", {
channels: myStatus.data
? t("onboarding.write.feedReadyChannels", {
count: myStatus.data.channels_total,
})
: "",
})}
</>
)}
{!importSubs.isError && (
<Trans
i18nKey="onboarding.write.rationale"
components={[<span className="text-fg/90" />]}
/>
)}
</p>
{importSubs.isError && (
<Button
variant="outline"
icon={<RefreshCw />}
loading={importSubs.isPending}
onClick={() => importSubs.mutate()}
className="mb-3"
>
{t("onboarding.write.retryImport")}
</Button>
)}
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<Button size="lg" icon={<ShieldCheck />} onClick={() => beginGrant("write")}>
{t("onboarding.write.enableUnsubscribe")}
</Button>
<Button
variant="ghost"
size="md"
onClick={() => {
endOnboarding();
onClose(false);
}}
>
{t("onboarding.write.keepReadOnly")}
</Button>
</div>
</>
)}
{step === "done" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 id={titleId} className="text-lg font-semibold">
{t("onboarding.done.title")}
</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
{t("onboarding.done.body")}
</p>
<Button
size="lg"
fullWidth
onClick={() => {
endOnboarding();
onClose(false);
}}
>
{t("onboarding.done.done")}
</Button>
</>
)}
<div className="mt-6">
<Dots index={stepIndex} total={3} />
</div>
</div>
{/* Toasts raised while the wizard is up (the connection notice among them) show above its
scrim, not under it — see ui/Toaster. */}
<Toaster overlay order={overlayOrder} />
</div>
</Overlay>
);
}
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
//
// Whether the onboarding wizard opens by itself is the account's preference: dismissed on one device,
// it stays shut on the others. A dismissal this browser kept before the server held it (the string
// "1") still counts, and reaches the server.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./OnboardingWizard", () => ({
default: ({ onClose }: { onClose: (dismiss: boolean) => void }) => (
<button onClick={() => onClose(true)}>skip the wizard</button>
),
}));
import { WizardProvider } from "./WizardProvider";
import { api } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys";
let save: ReturnType<typeof vi.spyOn>;
function mount(preferences: Record<string, unknown>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), {
id: 5,
can_read: false,
can_write: false,
google_enabled: true,
preferences,
});
render(
<QueryClientProvider client={qc}>
<WizardProvider>{null}</WizardProvider>
</QueryClientProvider>,
);
return qc;
}
const wizard = () => screen.queryByRole("button", { name: "skip the wizard" });
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
claimLocalData(5);
save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the onboarding wizard's auto-open", () => {
it("opens for an account that has not connected YouTube nor dismissed it", async () => {
mount({});
expect(await screen.findByRole("button", { name: "skip the wizard" })).toBeTruthy();
});
it("stays shut where the account dismissed it, on another device", async () => {
mount({ onboardingDismissed: true });
await act(async () => {});
expect(wizard()).toBeNull();
});
it("takes this browser's earlier dismissal and saves it to the account", async () => {
localStorage.setItem(LS.onboardingDismissed, "1");
mount({});
await act(async () => {});
expect(wizard()).toBeNull();
expect(save).toHaveBeenCalledWith({ onboardingDismissed: true });
});
it("saves a dismissal to the account, and stays shut", async () => {
const qc = mount({});
const skip = await screen.findByRole("button", { name: "skip the wizard" });
await act(async () => skip.click());
expect(save).toHaveBeenCalledWith({ onboardingDismissed: true });
expect(
qc.getQueryData<{ preferences: Record<string, unknown> }>(qk.me())?.preferences,
).toMatchObject({ onboardingDismissed: true });
expect(wizard()).toBeNull();
});
});
@@ -0,0 +1,76 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { qk } from "../../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../../lib/api";
import {
ONBOARDING_DISMISSED_OPTIONS,
ONBOARDING_DISMISSED_PREF,
shouldAutoOpenOnboarding,
} from "../../lib/onboarding";
import { prefValue, usePref } from "../../lib/usePref";
import { WizardActionsContext, type WizardActions } from "./wizardContext";
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
// Owns the onboarding wizard's open state (and the wizard render itself), lifted out of App so any
// module can trigger "connect YouTube" via a hook instead of an onOpenWizard prop threaded through
// the tree. Mirrors the other split-context providers; composed in main.tsx around <App/>.
//
// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from
// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (BootGate owns
// the fetching query), so it re-runs the auto-open check when the account's grants change.
//
// The actions context + hook live in wizardContext.ts.
export function WizardProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
// Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The setter only: the check below reads the value from `me`, which the setter keeps current.
const [, setDismissed] = usePref(ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS);
const dismissed = prefValue(me, ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS);
const openWizard = useCallback(() => setOpen(true), []);
const closeWizard = useCallback(() => setOpen(false), []);
// Leaving without the essential read step suppresses the auto-popup from then on.
const finishWizard = useCallback(
(dismiss: boolean) => {
if (dismiss) setDismissed(true);
setOpen(false);
},
[setDismissed],
);
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after each
// consent redirect). Re-checked when the account or its grants change.
// Keyed on the fields that matter, NOT the `me` object: the 60 s refetch hands out a new object,
// and re-running on that would reopen a wizard the user just closed. `can_write` is read by nothing
// here but is a trigger: a completed write grant resumes the flow.
const meId = me?.id;
const canRead = me?.can_read;
const canWrite = me?.can_write;
const googleEnabled = me?.google_enabled;
useEffect(() => {
if (meId == null || canRead == null) return;
const account = { can_read: canRead, google_enabled: googleEnabled };
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
if (shouldAutoOpenOnboarding(account, dismissed)) setOpen(true);
}, [meId, canRead, canWrite, googleEnabled, dismissed]);
const actions = useMemo<WizardActions>(
() => ({ openWizard, closeWizard }),
[openWizard, closeWizard],
);
return (
<WizardActionsContext.Provider value={actions}>
{children}
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
<Suspense fallback={null}>
{open && me && <OnboardingWizard me={me} onClose={finishWizard} />}
</Suspense>
</WizardActionsContext.Provider>
);
}
@@ -0,0 +1,20 @@
import { createContext, useContext } from "react";
// The onboarding wizard's actions context + hook, split out of WizardProvider so that file exports
// only components (Fast Refresh).
export interface WizardActions {
openWizard: () => void;
closeWizard: () => void;
}
// Only the actions are shared — the open flag stays local to the provider (it renders the wizard
// itself), so no consumer needs to subscribe to it.
export const WizardActionsContext = createContext<WizardActions>({
openWizard: () => {},
closeWizard: () => {},
});
export function useWizard(): WizardActions {
return useContext(WizardActionsContext);
}