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,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 };
}