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:
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,31 +47,40 @@ 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}
|
||||
minCol={minCol}
|
||||
colMaxWidth={colMaxWidth}
|
||||
maxCols={maxCols}
|
||||
rowEst={ROW_EST[view]}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
renderItem={(v, itemWidth) => (
|
||||
<VideoCard
|
||||
video={v}
|
||||
view={view}
|
||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||
width={thumb ? 0 : itemWidth}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<VirtualGrid
|
||||
items={items}
|
||||
getKey={(v) => v.id}
|
||||
minCol={minCol}
|
||||
colMaxWidth={colMaxWidth}
|
||||
maxCols={maxCols}
|
||||
rowEst={ROW_EST[view]}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
renderItem={(v, itemWidth) => (
|
||||
<VideoCard
|
||||
video={v}
|
||||
view={view}
|
||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||
width={thumb ? 0 : itemWidth}
|
||||
onState={onState}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user