From ccd5f3739a709b241cd8b421a6abc8c0fbb1b5aa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 05:07:06 +0200 Subject: [PATCH] feat(e24): pin to the comfort shelf from every card and row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- frontend/src/components/ChannelLayoutGrid.tsx | 5 + frontend/src/components/Channels.tsx | 15 ++ frontend/src/components/ComfortPinMenu.tsx | 50 ++++ frontend/src/components/Home.pin.dom.test.tsx | 237 ++++++++++++++++++ frontend/src/components/Home.tsx | 150 ++++++++++- frontend/src/components/Playlists.tsx | 5 + frontend/src/components/PlaylistsRail.tsx | 5 + frontend/src/components/PlexBrowse.tsx | 6 + .../src/components/PlexCollectionView.tsx | 6 + frontend/src/components/PlexDiscover.tsx | 6 + frontend/src/components/VideoCard.tsx | 10 + frontend/src/components/VirtualFeed.tsx | 61 +++-- .../src/components/comfortPin.dom.test.ts | 91 +++++++ frontend/src/components/comfortPin.tsx | 205 +++++++++++++++ frontend/src/components/plexCards.tsx | 6 + frontend/src/components/plexViews.tsx | 9 + frontend/src/components/ui/DataTable.tsx | 9 + frontend/src/i18n/locales/en/home.json | 16 +- frontend/src/i18n/locales/hu/home.json | 16 +- 19 files changed, 874 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/ComfortPinMenu.tsx create mode 100644 frontend/src/components/Home.pin.dom.test.tsx create mode 100644 frontend/src/components/comfortPin.dom.test.ts create mode 100644 frontend/src/components/comfortPin.tsx diff --git a/frontend/src/components/ChannelLayoutGrid.tsx b/frontend/src/components/ChannelLayoutGrid.tsx index 58544afe..aa44ddd3 100644 --- a/frontend/src/components/ChannelLayoutGrid.tsx +++ b/frontend/src/components/ChannelLayoutGrid.tsx @@ -24,6 +24,9 @@ interface ChannelLayoutGridProps { /** 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; emptyText?: string; } @@ -35,6 +38,7 @@ export default function ChannelLayoutGrid({ aboutKey, actionsKey, rowClassName, + rowProps, emptyText, }: ChannelLayoutGridProps) { const { t } = useTranslation(); @@ -50,6 +54,7 @@ export default function ChannelLayoutGrid({ const renderCard = (row: T) => (
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 1fff9b9c..54799ea6 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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(); // 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} /> ) : ( )} + pinChannel(c.id)} + title={(c) => c.title ?? t("channels.row.thisChannel")} + />
)} diff --git a/frontend/src/components/ComfortPinMenu.tsx b/frontend/src/components/ComfortPinMenu.tsx new file mode 100644 index 00000000..7a3c5e73 --- /dev/null +++ b/frontend/src/components/ComfortPinMenu.tsx @@ -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({ + menu, + target, + title, +}: { + menu: ContextMenuController; + target: (row: T) => PinTarget | null; + title: (row: T) => string; +}) { + const { t } = useTranslation(); + const pin = useComfortPin(menu.open); + const row = menu.target; + return ( + + ); +} + +/** 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 }) { + return c.title} />; +} + +/** The YouTube video lists (the feed grid, the Home rails). */ +export function VideoPinMenu({ menu }: { menu: ContextMenuController
))} + ); } @@ -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; +}) { 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 ( + c.title} /> ); } @@ -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(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(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(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} /> 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 (see ui/ContextMenu): the row it // belongs to is `rowMenu.target`, and the panel anchors to the pointer. const rowMenu = useContextMenu