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
+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")}