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