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.
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
// @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);
|
|
});
|
|
});
|