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