feat(e24): pin to the comfort shelf from every card and row

S3 wires the shelf's editor. The gesture is the E10 right-click menu, so the
decision "this is comfort media" can be made where the thing is — the feed, a
Plex grid, a channel row, a playlist — rather than only on the page that lists
the shelf.

One shared piece, `components/comfortPin.tsx`: the five pin-kind constructors,
the shelf query, and ONE `ActionMenuItem` that switches Pin/Unpin honestly (and
says "not known yet" with a disabled row until the shelf has answered, since a
confident "Pin" over something already pinned is exactly the quiet lie the house
rules forbid). Surfaces that already have a row menu compose the item in
(`Playlists`, `PlaylistsRail`); the rest mount `ComfortPinMenu`.

Two structural notes:

- The feed's menu lives in `VirtualFeed`, not in `Feed` — that is the list, and
  it is also what gives the channel page the gesture for free, since it renders
  `<Feed channelScoped>` all the way down.
- Cards take `menuProps` as a FUNCTION of the row, not the built props object.
  `triggerProps` is stable across renders while its return value is not, and a
  fresh object prop would blow past `VideoCard`'s memo for every card in the
  virtualized feed on every parent render.

`DataTable` and `ChannelLayoutGrid` grow a generic `rowProps` so a channel row
answers the same gesture in both of the manager's layouts; the table stays
domain-free and never learns what a menu is.

On Home the shelf tile's menu is the whole editor: four move rows (front / left
/ right / end, reusing the playlist menu's ordering idiom) plus the unpin, over
menu rows rather than drag & drop — dragging inside a horizontal, scrolling
Carousel fights the scroll, and menu rows are keyboard-reachable for free. The
reorder is optimistic on the cached payload and re-asks the server on failure
rather than showing an order that was never accepted.

The exhausted shelf tile (a pinned channel with nothing unwatched left) is
`aria-disabled`, not `disabled`: measured in Chromium, a disabled button still
receives contextmenu and pointerdown, but it loses focus — and with it Shift+F10,
the only keyboard route to the menu it most needs.

Verified end to end in the browser against the live account: all five pin kinds,
the reorder (persisted across a reload), the unpin, and the honest Unpin label on
a resume tile that is already on the shelf.
This commit is contained in:
2026-09-06 05:07:06 +02:00
parent f3c0b7be23
commit ccd5f3739a
19 changed files with 874 additions and 34 deletions
@@ -24,6 +24,9 @@ interface ChannelLayoutGridProps<T> {
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
actionsKey?: string;
rowClassName?: (row: T) => string;
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
* a channel answers the right-click in the card layout exactly as it does in the table. */
rowProps?: (row: T) => Record<string, unknown>;
emptyText?: string;
}
@@ -35,6 +38,7 @@ export default function ChannelLayoutGrid<T>({
aboutKey,
actionsKey,
rowClassName,
rowProps,
emptyText,
}: ChannelLayoutGridProps<T>) {
const { t } = useTranslation();
@@ -50,6 +54,7 @@ export default function ChannelLayoutGrid<T>({
const renderCard = (row: T) => (
<div
{...rowProps?.(row)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
+15
View File
@@ -15,6 +15,9 @@ import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { pinChannel } from "./comfortPin";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
@@ -412,6 +415,11 @@ export default function Channels() {
/>
);
const hasLayout = view === "subscribed" || view === "discovery";
// The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and
// the card layout — they are two paints of the same rows, so a right-click must mean the same
// thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a
// `Channel` row yet, so pinning one could only 404.
const pinMenu = useContextMenu<ManagedChannel>();
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
const sortControl = (
@@ -613,6 +621,7 @@ export default function Channels() {
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
/>
) : (
<ChannelLayoutGrid
@@ -623,8 +632,14 @@ export default function Channels() {
aboutKey="about"
actionsKey="actions"
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
/>
)}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinChannel(c.id)}
title={(c) => c.title ?? t("channels.row.thisChannel")}
/>
</div>
)}
</>
@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { type PlexCard, type Video } from "../lib/api";
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { type ContextMenuController } from "./ui/useContextMenu";
// The menu SURFACE for the comfort-shelf pin (E24 S3). Split from `comfortPin.tsx` for the same
// reason `ContextMenu.tsx` is split from `useContextMenu.ts`: a file that exports components and
// nothing else keeps react-refresh working for every surface that imports it.
/**
* The whole right-click menu for a surface whose only context action is the pin — the card grids and
* rails. Render ONE per list, outside the rows (see `ui/ContextMenu`), and spread
* `menu.triggerProps` on each row.
*
* `target` maps the row the user right-clicked to its pin; `title` names it in the confirmation
* toast. Both come from the call site because only it knows what its rows are.
*/
export function ComfortPinMenu<T>({
menu,
target,
title,
}: {
menu: ContextMenuController<T>;
target: (row: T) => PinTarget | null;
title: (row: T) => string;
}) {
const { t } = useTranslation();
const pin = useComfortPin(menu.open);
const row = menu.target;
return (
<ContextMenu
menu={menu}
ariaLabel={t("home.pin.menu")}
items={row ? pin.item(target(row), title(row)) : []}
/>
);
}
/** The Plex poster grids and rails — five of them, all pinning the same way. One component so the
* show-vs-item decision and the toast's name are written once rather than five times. */
export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> }) {
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
}
/** The YouTube video lists (the feed grid, the Home rails). */
export function VideoPinMenu({ menu }: { menu: ContextMenuController<Video> }) {
return <ComfortPinMenu menu={menu} target={(v) => pinVideo(v.id)} title={(v) => v.title ?? ""} />;
}
@@ -0,0 +1,237 @@
// @vitest-environment jsdom
//
// E24 S3: the comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
// sibling test's mocks would leak into.
//
// What is asserted here is what tsc cannot see: that the menu the user gets over a SHELF tile is a
// different menu from the one over a resume tile, that a move row is disabled exactly when the move
// would go nowhere, and that a move writes the shelf's WHOLE new order rather than the pair it
// swapped.
//
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, wrap in a FloatingTree,
// and match menu items by text with `{ hidden: true }` (floating-ui never positions in jsdom, so the
// surface stays visibility:hidden and its contents have no accessible name).
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FloatingTree } from "@floating-ui/react";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
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 discover = vi.fn();
const comfortPins = vi.fn();
const addComfortPin = vi.fn();
const removeComfortPin = vi.fn();
const reorderComfortPins = vi.fn();
vi.mock("../lib/api", () => ({
api: {
discover: (...a: unknown[]) => discover(...a),
discoverPick: () => new Promise(() => {}),
setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(),
watchLaterRemove: () => Promise.resolve(),
comfortPins: (...a: unknown[]) => comfortPins(...a),
addComfortPin: (...a: unknown[]) => addComfortPin(...a),
removeComfortPin: (...a: unknown[]) => removeComfortPin(...a),
reorderComfortPins: (...a: unknown[]) => reorderComfortPins(...a),
},
HttpError: class extends Error {},
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
const video = (id: string, title: string) => ({
id,
title,
channel_id: "UC1",
channel_title: "A channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/channel/UC1",
published_at: "2026-01-01T00:00:00Z",
thumbnail_url: null,
duration_seconds: 600,
view_count: 10,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 0,
saved: false,
watch_url: `https://www.youtube.com/watch?v=${id}`,
});
const base = (id: string, title: string) => ({
source: "youtube",
kind: "video",
id,
title,
subtitle: null,
thumb: null,
aspect: "wide",
duration_seconds: 600,
position_seconds: 0,
remaining_seconds: 600,
status: "new",
plex: null,
video: video(id, title),
});
/** A pinned CHANNEL — the shelf tile with no native card, and the one that is easiest to address by
* its title in a test (the video tiles render a whole VideoCard). */
const channelPin = (id: string, title: string, pinId: number) => ({
...base(id, title),
kind: "channel",
video: null,
unwatched_count: 3,
pin_id: pinId,
next: base(`${id}-next`, `${title} next`),
});
const shelf = [
channelPin("UCa", "Comfort A", 3),
channelPin("UCb", "Comfort B", 7),
channelPin("UCc", "Comfort C", 9),
];
const payload = () => ({
budget: "any",
hero: null,
continue_watching: [base("v2", "Resume thing")],
comfort: shelf,
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<FloatingTree>
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>
</FloatingTree>,
);
}
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
const item = (label: string) => {
const found = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
return found as HTMLButtonElement;
};
beforeEach(() => {
discover.mockReset().mockResolvedValue(payload());
comfortPins.mockReset().mockResolvedValue({ items: shelf });
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
removeComfortPin.mockReset().mockResolvedValue({ deleted: 1 });
reorderComfortPins.mockReset().mockResolvedValue({ ok: true });
});
afterEach(cleanup);
describe("Home — the comfort shelf's context menu", () => {
it("offers the four moves and the unpin over a SHELF tile", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort B"));
expect(labels()).toEqual([
"home.hero.play",
"home.shelf.moveFirst",
"home.shelf.moveLeft",
"home.shelf.moveRight",
"home.shelf.moveLast",
"home.pin.remove",
]);
});
it("disables exactly the moves that would go nowhere", async () => {
mount();
// The first tile can go right, but not left or to the front it is already at.
fireEvent.contextMenu(await screen.findByText("Comfort A"));
expect(item("home.shelf.moveFirst").disabled).toBe(true);
expect(item("home.shelf.moveLeft").disabled).toBe(true);
expect(item("home.shelf.moveRight").disabled).toBe(false);
expect(item("home.shelf.moveLast").disabled).toBe(false);
});
it("writes the WHOLE new order when a tile moves", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
// Not the swapped pair: the endpoint takes the shelf's complete order, and a short list would be
// the server reordering half the shelf.
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
});
it("unpins with the pin id the shelf card carries", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort C"));
fireEvent.click(item("home.pin.remove"));
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(9));
});
it("offers the plain PIN over a resume tile, and never a move", async () => {
// The rail below the shelf is not orderable, so its menu must not grow rows that would reorder
// something the tile is not part of.
mount();
await screen.findByText("Comfort A");
fireEvent.contextMenu(screen.getByText("Resume thing"));
await waitFor(() => expect(labels()).toContain("home.pin.add"));
expect(labels().some((l) => l?.startsWith("home.shelf.move"))).toBe(false);
fireEvent.click(item("home.pin.add"));
await waitFor(() => expect(addComfortPin).toHaveBeenCalledWith("video", "v2"));
});
it("still opens the menu over a shelf tile that has nothing left to play", async () => {
// The tile with nothing left to play is the one you most want to take OFF the shelf, so its
// menu still has to open and its Play row still has to be honest about being dead.
// NOTE what this does NOT prove: jsdom delivers `contextmenu` to a real `<button disabled>`
// too, so swapping the tile's `aria-disabled` back to `disabled` keeps this test green. That
// choice was settled in a real browser instead (see the tile's own comment) — the difference
// is focus, which jsdom's event dispatch never exercises.
discover.mockResolvedValue({
...payload(),
comfort: [{ ...channelPin("UCd", "Nothing left", 11), next: null }],
});
mount();
fireEvent.contextMenu(await screen.findByText("Nothing left"));
expect(item("home.hero.play").disabled).toBe(true);
fireEvent.click(item("home.pin.remove"));
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
});
it("says so rather than guessing while the shelf is still loading", async () => {
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
// trustworthy once the shelf has answered.
comfortPins.mockReturnValue(new Promise(() => {}));
mount();
await screen.findByText("Comfort A");
fireEvent.contextMenu(screen.getByText("Resume thing"));
expect(item("home.pin.add").disabled).toBe(true);
});
});
+143 -7
View File
@@ -1,22 +1,37 @@
import { lazy, Suspense, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, RotateCcw } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
PinOff,
Play,
RotateCcw,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
api,
discoverCardKey,
type DiscoverBudget,
type DiscoverCard,
type DiscoverPayload,
type HeroTier,
type PlexCard,
type Video,
type VideoStatus,
} from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatDuration } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
@@ -50,6 +65,14 @@ const YT_SLOT = 200;
// (`hero.SURPRISE_DRAW`), which has to be able to step past a full window.
const SEEN_WINDOW = 8;
// The four shelf moves, in the order they appear in the menu.
const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
["first", "home.shelf.moveFirst", ChevronsLeft],
["left", "home.shelf.moveLeft", ChevronLeft],
["right", "home.shelf.moveRight", ChevronRight],
["last", "home.shelf.moveLast", ChevronsRight],
];
function Rail({
title,
ariaLabel,
@@ -60,6 +83,7 @@ function Rail({
onState,
onToggleSave,
onToggleWatched,
onMove,
}: {
title: string;
ariaLabel: string;
@@ -70,7 +94,55 @@ function Rail({
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
/** Only the comfort shelf is orderable. Given, this rail's menu grows the four move rows; omitted,
* its tiles offer the plain pin instead. */
onMove?: (card: DiscoverCard, to: ShelfMove) => void;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
const target = menu.target;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: shelfMoveTarget(shelfIdx, to, cards.length) < 0,
onSelect: () => onMove(target, to),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(target), target.title)),
];
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
// you have not pinned anything teaches nothing about how to fill it.
if (!cards.length) {
@@ -101,6 +173,10 @@ function Rail({
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
// Bound to the UNIFIED card, not the native payload the card component holds: the
// menu acts on the shelf ENTRY (its pin id, its place in the order), which the
// `Video` inside it does not carry.
menuProps={() => menu.triggerProps(c)}
/>
) : c.plex ? (
<PlexPosterCard
@@ -108,13 +184,15 @@ function Rail({
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched}
menuProps={() => menu.triggerProps(c)}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} />
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
)}
</div>
))}
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
</section>
);
}
@@ -122,7 +200,15 @@ function Rail({
// A pinned CHANNEL or PLAYLIST has no native card of its own — neither is a playable thing, so
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
// runtime it does not have.
function ShelfTile({ card, onPlay }: { card: DiscoverCard; onPlay: (card: DiscoverCard) => void }) {
function ShelfTile({
card,
onPlay,
menuProps,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
}) {
const { t } = useTranslation();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel =
@@ -132,9 +218,17 @@ function ShelfTile({ card, onPlay }: { card: DiscoverCard; onPlay: (card: Discov
return (
<button
type="button"
onClick={() => onPlay(card)}
disabled={!card.next}
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface disabled:opacity-60 enabled:hover:border-accent transition-colors"
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths.
aria-disabled={!card.next}
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
@@ -163,6 +257,9 @@ function Hero({
rerolling: boolean;
}) {
const { t } = useTranslation();
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
// answered with — so it answers the same right-click every card in the app does.
const pinMenu = useContextMenu<DiscoverCard>();
const started = card.position_seconds > 0;
const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
@@ -177,7 +274,10 @@ function Hero({
: t("home.hero.surprise");
return (
<section className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]">
<section
{...pinMenu.triggerProps(card)}
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
>
<button
type="button"
onClick={() => onPlay(card)}
@@ -222,6 +322,7 @@ function Hero({
</Button>
</div>
</div>
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => c.title} />
</section>
);
}
@@ -344,6 +445,40 @@ export default function Home() {
[qc, t],
);
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
// tile moves under the pointer, then written; a failure re-asks the server rather than leaving the
// page showing an order the server never accepted (the same lesson the playlist reorder learned).
const moveInShelf = useCallback(
(card: DiscoverCard, to: ShelfMove) => {
const key = qk.discover(budget);
const before = qc.getQueryData<DiscoverPayload>(key);
const cards = before?.comfort ?? [];
const from = cards.findIndex((c) => c.pin_id === card.pin_id);
const target = shelfMoveTarget(from, to, cards.length);
if (target < 0) return;
const next = [...cards];
const [moved] = next.splice(from, 1);
if (!moved) return; // unreachable: `from` is in range whenever `target` is
next.splice(target, 0, moved);
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: next } : old));
// `pin_id` is present on every shelf card by construction; the filter is what proves it to the
// type, and a short list would be rejected by the server rather than silently reordering half
// the shelf.
const ids = next.map((c) => c.pin_id).filter((id): id is number => id != null);
void api
.reorderComfortPins(ids)
.catch((e: unknown) => {
qc.setQueryData<DiscoverPayload>(key, before);
notifyActionError(e, t("home.shelf.moveFailed"));
})
.finally(() => {
// The shelf is also the pin menus' source of truth everywhere else in the app.
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
});
},
[budget, qc, t],
);
const reroll = useCallback(() => {
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
@@ -413,6 +548,7 @@ export default function Home() {
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
onMove={moveInShelf}
/>
<Rail
title={t("home.continue.title")}
+5
View File
@@ -50,6 +50,7 @@ import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinVideo } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar";
@@ -220,6 +221,9 @@ export default function Playlists() {
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
const rowMenu = useContextMenu<Video>();
// The comfort-shelf pin composes into the row's existing menu (E24 S3) rather than mounting a
// second one on the same <tr>.
const comfortPin = useComfortPin(rowMenu.open);
const listQuery = useQuery({
queryKey: qk.playlists(),
@@ -613,6 +617,7 @@ export default function Playlists() {
icon: <Play className="w-4 h-4" />,
onSelect: () => setPlayingId(ctxRow.id),
},
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
...(canEdit
? ([
moveTargets.length
@@ -12,6 +12,7 @@ import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinPlaylist } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { type PanelItem } from "./ui/PanelGroups";
@@ -74,6 +75,9 @@ export default function PlaylistsRail({
const plActions = usePlaylistActions();
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
const railMenu = useContextMenu<Playlist>();
// The comfort-shelf pin composes INTO this menu rather than mounting a second one: a row can only
// have one right-click menu, and rename/delete/pin are all things you do to the same playlist.
const comfortPin = useComfortPin(railMenu.open);
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
// context menu is that the action lands where the gesture did.
const [renamingId, setRenamingId] = useState<number | null>(null);
@@ -226,6 +230,7 @@ export default function PlaylistsRail({
setRenamingId(ctxPl.id);
},
},
...comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
{
id: "delete",
label: t("playlists.delete"),
+6
View File
@@ -38,6 +38,8 @@ import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilter
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePublishPlexView } from "./PlexViewBridge";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap";
@@ -172,6 +174,8 @@ export default function PlexBrowse() {
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
// different title's info starts at the top.
const infoScrollRef = useRef(0);
// One comfort-pin menu for the titles grid (see ui/ContextMenu).
const pinMenu = useContextMenu<PlexCard>();
const scroller = () => document.querySelector("main");
useLayoutEffect(() => {
const el = scroller();
@@ -606,6 +610,7 @@ export default function PlexBrowse() {
onOpen={() => onCard(c)}
onInfo={() => onInfo(c)}
onToggleWatched={toggleWatched}
menuProps={pinMenu.triggerProps}
/>
))}
</div>
@@ -631,6 +636,7 @@ export default function PlexBrowse() {
</>
)}
<PlexPinMenu menu={pinMenu} />
<div ref={setSentinelEl} className="h-8" />
{isFetchingNextPage && (
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
@@ -11,6 +11,8 @@ import { showEpisodeKeys } from "../lib/plexQueue";
import { useConfirm } from "./ConfirmProvider";
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState";
@@ -47,6 +49,8 @@ export default function PlexCollectionView({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// One comfort-pin menu for the member grid (see ui/ContextMenu).
const pinMenu = useContextMenu<PlexCard>();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const q = useQuery({
queryKey: qk.plexCollections("detail", collectionId),
@@ -348,6 +352,7 @@ export default function PlexCollectionView({
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched}
menuProps={pinMenu.triggerProps}
/>
{canEdit && (
<button
@@ -366,6 +371,7 @@ export default function PlexCollectionView({
)}
</>
)}
<PlexPinMenu menu={pinMenu} />
</div>
);
}
+6
View File
@@ -10,6 +10,8 @@ import { toggleWatchState } from "../lib/plexWatch";
import { usePlex } from "./PlexProvider";
import { Carousel } from "./ui/Carousel";
import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
@@ -40,6 +42,8 @@ function CardRail({
onToggleWatched: (c: PlexCard) => void;
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
}) {
// One comfort-pin menu per rail (see ui/ContextMenu), not one per poster.
const pinMenu = useContextMenu<PlexCard>();
const heading =
action != null ? (
<span className="inline-flex items-center gap-2">
@@ -59,10 +63,12 @@ function CardRail({
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={onToggleWatched}
menuProps={pinMenu.triggerProps}
/>
</div>
))}
</Carousel>
<PlexPinMenu menu={pinMenu} />
</section>
);
}
+10
View File
@@ -385,6 +385,7 @@ function VideoCard({
onResetState,
onToggleSave,
onOpen,
menuProps,
}: {
video: Video;
view: FeedView;
@@ -394,6 +395,12 @@ function VideoCard({
onResetState?: (id: string) => 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 (E24 S3 — the comfort pin).
* 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>;
}) {
const { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions();
@@ -521,6 +528,7 @@ function VideoCard({
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
@@ -583,6 +591,7 @@ function VideoCard({
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
>
<Thumb
@@ -616,6 +625,7 @@ function VideoCard({
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
"flex flex-col",
+11
View File
@@ -1,6 +1,8 @@
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
import { VideoPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import VirtualGrid from "./ui/VirtualGrid";
// Estimated row heights before measurement; real heights are measured per-row so these only affect
@@ -45,7 +47,13 @@ export default function VirtualFeed({
fetchNextPage,
}: VirtualFeedProps) {
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
// The comfort-shelf pin (E24 S3) lives HERE rather than in each of the feed's callers: this is the
// list, and `useContextMenu`'s contract is one menu per list. Mounting it at this level is also
// what gives the channel page the gesture for free — it renders `<Feed channelScoped>`, so it is
// the same component all the way down.
const pinMenu = useContextMenu<Video>();
return (
<>
<VirtualGrid
items={items}
getKey={(v) => v.id}
@@ -68,8 +76,11 @@ export default function VirtualFeed({
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={onOpen}
menuProps={pinMenu.triggerProps}
/>
)}
/>
<VideoPinMenu menu={pinMenu} />
</>
);
}
@@ -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 (E24 S3). 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);
});
});
+205
View File
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react";
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 "./ui/ActionMenu";
// E24 S3 — "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 on every card and row in the app:
// you decide something is comfort media while you are watching it, not while you are on a page that
// already lists it. So this file is the one place the pin lives, and every surface composes the same
// `ActionMenuItem` into the menu it already has (`Playlists`, `PlaylistsRail`) or mounts the
// `ComfortPinMenu` wrapper when it has none.
//
// 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. The MENU SURFACE that
// renders this item lives in `ComfortPinMenu.tsx` — the same hook/component split, for the same
// react-refresh reason, as `useContextMenu.ts` beside `ContextMenu.tsx`.
/** 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: DiscoverCard): 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 {
/** The pin's id when the target is on the shelf, `null` when it is not, `undefined` while the
* shelf is still being fetched — three states, because "not pinned" and "not known yet" must not
* render as the same row. */
pinIdOf: (target: PinTarget) => number | null | undefined;
toggle: (target: PinTarget, title: string) => void;
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 } = 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);
useEffect(() => {
if (open) setEverOpen(true);
}, [open]);
const shelf = useQuery({
queryKey: qk.comfortPins(),
queryFn: () => api.comfortPins(),
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,
onSelect: () => toggle(target, title),
},
];
},
[pinIdOf, t, toggle],
);
return { pinIdOf, toggle, remove, item };
}
+6
View File
@@ -51,11 +51,16 @@ export function PlexPosterCard({
onOpen,
onInfo,
onToggleWatched,
menuProps,
}: {
card: PlexCard;
onOpen: () => void;
onInfo: () => void;
onToggleWatched: (c: PlexCard) => void;
/** Right-click / long-press props for the GRID's one context menu (E24 S3 — the comfort pin).
* A function of the card rather than a ready-made object, so every card surface in the app takes
* `menuProps={menu.triggerProps}` and reads the same at every call site. */
menuProps?: (c: PlexCard) => Record<string, unknown>;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
@@ -69,6 +74,7 @@ export function PlexPosterCard({
return (
<div
className="group text-left"
{...menuProps?.(card)}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
+9
View File
@@ -22,6 +22,8 @@ import {
WatchedToggle,
} from "../lib/plexDetailUi";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
@@ -106,6 +108,10 @@ export function PlexPersonView({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
// ONE comfort-pin menu for this whole page — the "known for" rail and the two filmography grids
// are three lists on one screen, and the popover anchors at the pointer, so a second controller
// would only mean a second closed popover to keep shut (see ui/ContextMenu).
const pinMenu = useContextMenu<PlexCard>();
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
@@ -153,6 +159,7 @@ export function PlexPersonView({
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
menuProps={pinMenu.triggerProps}
/>
))}
</div>
@@ -242,6 +249,7 @@ export function PlexPersonView({
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
menuProps={pinMenu.triggerProps}
/>
</div>
))}
@@ -259,6 +267,7 @@ export function PlexPersonView({
)}
</div>
)}
<PlexPinMenu menu={pinMenu} />
</div>
);
}
+9
View File
@@ -50,6 +50,7 @@ export default function DataTable<T>({
persistKey,
emptyText,
rowClassName,
rowProps,
controlsPosition = "bottom",
controlsLeading,
controlsInBand = false,
@@ -70,6 +71,12 @@ export default function DataTable<T>({
persistKey?: string;
emptyText?: string;
rowClassName?: (row: T) => string;
// Extra DOM props per row — the escape hatch a caller needs to hang an INPUT PATH on a row it does
// not own the markup of. Today's only consumer is `useContextMenu().triggerProps` (right-click /
// long-press / Shift+F10), and both row shapes get it, so the wide table and the narrow card
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
// and never learns what a menu is.
rowProps?: (row: T) => Record<string, unknown>;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
@@ -253,6 +260,7 @@ export default function DataTable<T>({
{paged.map((row) => (
<tr
key={rowKey(row)}
{...rowProps?.(row)}
className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? ""
}`}
@@ -278,6 +286,7 @@ export default function DataTable<T>({
{paged.map((row) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
+15 -1
View File
@@ -32,6 +32,20 @@
"unwatchedCount_one": "{{count}} unwatched",
"unwatchedCount_other": "{{count}} unwatched",
"itemCount_one": "{{count}} video",
"itemCount_other": "{{count}} videos"
"itemCount_other": "{{count}} videos",
"moveFirst": "To the front",
"moveLeft": "Left",
"moveRight": "Right",
"moveLast": "To the end",
"moveFailed": "Could not reorder the shelf."
},
"pin": {
"menu": "Comfort shelf",
"add": "Pin to comfort shelf",
"remove": "Remove from shelf",
"added": "{{title}} is on your comfort shelf.",
"removed": "{{title}} is off your comfort shelf.",
"addFailed": "Could not pin it to the shelf.",
"removeFailed": "Could not remove it from the shelf."
}
}
+15 -1
View File
@@ -32,6 +32,20 @@
"unwatchedCount_one": "{{count}} megnézetlen",
"unwatchedCount_other": "{{count}} megnézetlen",
"itemCount_one": "{{count}} videó",
"itemCount_other": "{{count}} videó"
"itemCount_other": "{{count}} videó",
"moveFirst": "A polc elejére",
"moveLeft": "Balra",
"moveRight": "Jobbra",
"moveLast": "A polc végére",
"moveFailed": "Nem sikerült átrendezni a polcot."
},
"pin": {
"menu": "Komfort polc",
"add": "Komfort polcra",
"remove": "Levétel a polcról",
"added": "{{title}} felkerült a komfort polcra.",
"removed": "{{title}} lekerült a komfort polcról.",
"addFailed": "Nem sikerült a polcra tűzni.",
"removeFailed": "Nem sikerült levenni a polcról."
}
}