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:
@@ -24,6 +24,9 @@ interface ChannelLayoutGridProps<T> {
|
|||||||
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||||
actionsKey?: string;
|
actionsKey?: string;
|
||||||
rowClassName?: (row: T) => 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<string, unknown>;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
aboutKey,
|
aboutKey,
|
||||||
actionsKey,
|
actionsKey,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
|
rowProps,
|
||||||
emptyText,
|
emptyText,
|
||||||
}: ChannelLayoutGridProps<T>) {
|
}: ChannelLayoutGridProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -50,6 +54,7 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
|
|
||||||
const renderCard = (row: T) => (
|
const renderCard = (row: T) => (
|
||||||
<div
|
<div
|
||||||
|
{...rowProps?.(row)}
|
||||||
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import Tooltip from "./ui/Tooltip";
|
|||||||
import DataTable from "./ui/DataTable";
|
import DataTable from "./ui/DataTable";
|
||||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
import Tabs, { type TabDef } from "./ui/Tabs";
|
||||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||||
|
import { ComfortPinMenu } from "./ComfortPinMenu";
|
||||||
|
import { pinChannel } from "./comfortPin";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||||
import { useTagSuggestions } from "./useTagSuggestions";
|
import { useTagSuggestions } from "./useTagSuggestions";
|
||||||
@@ -412,6 +415,11 @@ export default function Channels() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const hasLayout = view === "subscribed" || view === "discovery";
|
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<ManagedChannel>();
|
||||||
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
|
// 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.
|
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||||
const sortControl = (
|
const sortControl = (
|
||||||
@@ -613,6 +621,7 @@ export default function Channels() {
|
|||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
fixedLayout
|
fixedLayout
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
|
rowProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChannelLayoutGrid
|
<ChannelLayoutGrid
|
||||||
@@ -623,8 +632,14 @@ export default function Channels() {
|
|||||||
aboutKey="about"
|
aboutKey="about"
|
||||||
actionsKey="actions"
|
actionsKey="actions"
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
|
rowProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ComfortPinMenu
|
||||||
|
menu={pinMenu}
|
||||||
|
target={(c) => pinChannel(c.id)}
|
||||||
|
title={(c) => c.title ?? t("channels.row.thisChannel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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<T>({
|
||||||
|
menu,
|
||||||
|
target,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
menu: ContextMenuController<T>;
|
||||||
|
target: (row: T) => PinTarget | null;
|
||||||
|
title: (row: T) => string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pin = useComfortPin(menu.open);
|
||||||
|
const row = menu.target;
|
||||||
|
return (
|
||||||
|
<ContextMenu
|
||||||
|
menu={menu}
|
||||||
|
ariaLabel={t("home.pin.menu")}
|
||||||
|
items={row ? pin.item(target(row), title(row)) : []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<PlexCard> }) {
|
||||||
|
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The YouTube video lists (the feed grid, the Home rails). */
|
||||||
|
export function VideoPinMenu({ menu }: { menu: ContextMenuController<Video> }) {
|
||||||
|
return <ComfortPinMenu menu={menu} target={(v) => pinVideo(v.id)} title={(v) => v.title ?? ""} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E24 S3: the comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
|
||||||
|
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
|
||||||
|
// sibling test's mocks would leak into.
|
||||||
|
//
|
||||||
|
// What is asserted here is what tsc cannot see: that the menu the user gets over a SHELF tile is a
|
||||||
|
// different menu from the one over a resume tile, that a move row is disabled exactly when the move
|
||||||
|
// would go nowhere, and that a move writes the shelf's WHOLE new order rather than the pair it
|
||||||
|
// swapped.
|
||||||
|
//
|
||||||
|
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, wrap in a FloatingTree,
|
||||||
|
// and match menu items by text with `{ hidden: true }` (floating-ui never positions in jsdom, so the
|
||||||
|
// surface stays visibility:hidden and its contents have no accessible name).
|
||||||
|
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const discover = vi.fn();
|
||||||
|
const comfortPins = vi.fn();
|
||||||
|
const addComfortPin = vi.fn();
|
||||||
|
const removeComfortPin = vi.fn();
|
||||||
|
const reorderComfortPins = vi.fn();
|
||||||
|
vi.mock("../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
discover: (...a: unknown[]) => discover(...a),
|
||||||
|
discoverPick: () => new Promise(() => {}),
|
||||||
|
setState: () => Promise.resolve(),
|
||||||
|
watchLaterAdd: () => Promise.resolve(),
|
||||||
|
watchLaterRemove: () => Promise.resolve(),
|
||||||
|
comfortPins: (...a: unknown[]) => comfortPins(...a),
|
||||||
|
addComfortPin: (...a: unknown[]) => addComfortPin(...a),
|
||||||
|
removeComfortPin: (...a: unknown[]) => removeComfortPin(...a),
|
||||||
|
reorderComfortPins: (...a: unknown[]) => reorderComfortPins(...a),
|
||||||
|
},
|
||||||
|
HttpError: class extends Error {},
|
||||||
|
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
|
||||||
|
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
|
||||||
|
|
||||||
|
import Home from "./Home";
|
||||||
|
|
||||||
|
const video = (id: string, title: string) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
channel_id: "UC1",
|
||||||
|
channel_title: "A channel",
|
||||||
|
channel_thumbnail: null,
|
||||||
|
channel_url: "https://youtube.com/channel/UC1",
|
||||||
|
published_at: "2026-01-01T00:00:00Z",
|
||||||
|
thumbnail_url: null,
|
||||||
|
duration_seconds: 600,
|
||||||
|
view_count: 10,
|
||||||
|
is_short: false,
|
||||||
|
live_status: "none",
|
||||||
|
status: "new",
|
||||||
|
position_seconds: 0,
|
||||||
|
saved: false,
|
||||||
|
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = (id: string, title: string) => ({
|
||||||
|
source: "youtube",
|
||||||
|
kind: "video",
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
subtitle: null,
|
||||||
|
thumb: null,
|
||||||
|
aspect: "wide",
|
||||||
|
duration_seconds: 600,
|
||||||
|
position_seconds: 0,
|
||||||
|
remaining_seconds: 600,
|
||||||
|
status: "new",
|
||||||
|
plex: null,
|
||||||
|
video: video(id, title),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A pinned CHANNEL — the shelf tile with no native card, and the one that is easiest to address by
|
||||||
|
* its title in a test (the video tiles render a whole VideoCard). */
|
||||||
|
const channelPin = (id: string, title: string, pinId: number) => ({
|
||||||
|
...base(id, title),
|
||||||
|
kind: "channel",
|
||||||
|
video: null,
|
||||||
|
unwatched_count: 3,
|
||||||
|
pin_id: pinId,
|
||||||
|
next: base(`${id}-next`, `${title} next`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const shelf = [
|
||||||
|
channelPin("UCa", "Comfort A", 3),
|
||||||
|
channelPin("UCb", "Comfort B", 7),
|
||||||
|
channelPin("UCc", "Comfort C", 9),
|
||||||
|
];
|
||||||
|
|
||||||
|
const payload = () => ({
|
||||||
|
budget: "any",
|
||||||
|
hero: null,
|
||||||
|
continue_watching: [base("v2", "Resume thing")],
|
||||||
|
comfort: shelf,
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<FloatingTree>
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Home />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</FloatingTree>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||||
|
const item = (label: string) => {
|
||||||
|
const found = screen
|
||||||
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === label);
|
||||||
|
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||||
|
return found as HTMLButtonElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
discover.mockReset().mockResolvedValue(payload());
|
||||||
|
comfortPins.mockReset().mockResolvedValue({ items: shelf });
|
||||||
|
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
|
||||||
|
removeComfortPin.mockReset().mockResolvedValue({ deleted: 1 });
|
||||||
|
reorderComfortPins.mockReset().mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Home — the comfort shelf's context menu", () => {
|
||||||
|
it("offers the four moves and the unpin over a SHELF tile", async () => {
|
||||||
|
mount();
|
||||||
|
fireEvent.contextMenu(await screen.findByText("Comfort B"));
|
||||||
|
expect(labels()).toEqual([
|
||||||
|
"home.hero.play",
|
||||||
|
"home.shelf.moveFirst",
|
||||||
|
"home.shelf.moveLeft",
|
||||||
|
"home.shelf.moveRight",
|
||||||
|
"home.shelf.moveLast",
|
||||||
|
"home.pin.remove",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables exactly the moves that would go nowhere", async () => {
|
||||||
|
mount();
|
||||||
|
// The first tile can go right, but not left or to the front it is already at.
|
||||||
|
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||||
|
expect(item("home.shelf.moveFirst").disabled).toBe(true);
|
||||||
|
expect(item("home.shelf.moveLeft").disabled).toBe(true);
|
||||||
|
expect(item("home.shelf.moveRight").disabled).toBe(false);
|
||||||
|
expect(item("home.shelf.moveLast").disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the WHOLE new order when a tile moves", async () => {
|
||||||
|
mount();
|
||||||
|
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||||
|
fireEvent.click(item("home.shelf.moveRight"));
|
||||||
|
// Not the swapped pair: the endpoint takes the shelf's complete order, and a short list would be
|
||||||
|
// the server reordering half the shelf.
|
||||||
|
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpins with the pin id the shelf card carries", async () => {
|
||||||
|
mount();
|
||||||
|
fireEvent.contextMenu(await screen.findByText("Comfort C"));
|
||||||
|
fireEvent.click(item("home.pin.remove"));
|
||||||
|
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(9));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the plain PIN over a resume tile, and never a move", async () => {
|
||||||
|
// The rail below the shelf is not orderable, so its menu must not grow rows that would reorder
|
||||||
|
// something the tile is not part of.
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Comfort A");
|
||||||
|
fireEvent.contextMenu(screen.getByText("Resume thing"));
|
||||||
|
await waitFor(() => expect(labels()).toContain("home.pin.add"));
|
||||||
|
expect(labels().some((l) => l?.startsWith("home.shelf.move"))).toBe(false);
|
||||||
|
fireEvent.click(item("home.pin.add"));
|
||||||
|
await waitFor(() => expect(addComfortPin).toHaveBeenCalledWith("video", "v2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still opens the menu over a shelf tile that has nothing left to play", async () => {
|
||||||
|
// The tile with nothing left to play is the one you most want to take OFF the shelf, so its
|
||||||
|
// menu still has to open and its Play row still has to be honest about being dead.
|
||||||
|
// NOTE what this does NOT prove: jsdom delivers `contextmenu` to a real `<button disabled>`
|
||||||
|
// too, so swapping the tile's `aria-disabled` back to `disabled` keeps this test green. That
|
||||||
|
// choice was settled in a real browser instead (see the tile's own comment) — the difference
|
||||||
|
// is focus, which jsdom's event dispatch never exercises.
|
||||||
|
discover.mockResolvedValue({
|
||||||
|
...payload(),
|
||||||
|
comfort: [{ ...channelPin("UCd", "Nothing left", 11), next: null }],
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
fireEvent.contextMenu(await screen.findByText("Nothing left"));
|
||||||
|
expect(item("home.hero.play").disabled).toBe(true);
|
||||||
|
fireEvent.click(item("home.pin.remove"));
|
||||||
|
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so rather than guessing while the shelf is still loading", async () => {
|
||||||
|
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
|
||||||
|
// trustworthy once the shelf has answered.
|
||||||
|
comfortPins.mockReturnValue(new Promise(() => {}));
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Comfort A");
|
||||||
|
fireEvent.contextMenu(screen.getByText("Resume thing"));
|
||||||
|
expect(item("home.pin.add").disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,37 @@
|
|||||||
import { lazy, Suspense, useCallback, useState } from "react";
|
import { lazy, Suspense, useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { qk } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
discoverCardKey,
|
discoverCardKey,
|
||||||
type DiscoverBudget,
|
type DiscoverBudget,
|
||||||
type DiscoverCard,
|
type DiscoverCard,
|
||||||
|
type DiscoverPayload,
|
||||||
type HeroTier,
|
type HeroTier,
|
||||||
type PlexCard,
|
type PlexCard,
|
||||||
type Video,
|
type Video,
|
||||||
type VideoStatus,
|
type VideoStatus,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
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 { Button } from "./ui/Button";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import VideoCard from "./VideoCard";
|
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.
|
// (`hero.SURPRISE_DRAW`), which has to be able to step past a full window.
|
||||||
const SEEN_WINDOW = 8;
|
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({
|
function Rail({
|
||||||
title,
|
title,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
@@ -60,6 +83,7 @@ function Rail({
|
|||||||
onState,
|
onState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onToggleWatched,
|
onToggleWatched,
|
||||||
|
onMove,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -70,7 +94,55 @@ function Rail({
|
|||||||
onState: (id: string, status: VideoStatus) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onToggleWatched: (card: PlexCard) => 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
|
// 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.
|
// you have not pinned anything teaches nothing about how to fill it.
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
@@ -101,6 +173,10 @@ function Rail({
|
|||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
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 ? (
|
) : c.plex ? (
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
@@ -108,13 +184,15 @@ function Rail({
|
|||||||
onOpen={() => onPlay(c)}
|
onOpen={() => onPlay(c)}
|
||||||
onInfo={() => onInfo(c)}
|
onInfo={() => onInfo(c)}
|
||||||
onToggleWatched={onToggleWatched}
|
onToggleWatched={onToggleWatched}
|
||||||
|
menuProps={() => menu.triggerProps(c)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ShelfTile card={c} onPlay={onPlay} />
|
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
||||||
</section>
|
</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
|
// 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
|
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
|
||||||
// runtime it does not have.
|
// 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 { t } = useTranslation();
|
||||||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||||
const countLabel =
|
const countLabel =
|
||||||
@@ -132,9 +218,17 @@ function ShelfTile({ card, onPlay }: { card: DiscoverCard; onPlay: (card: Discov
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPlay(card)}
|
onClick={() => card.next && onPlay(card)}
|
||||||
disabled={!card.next}
|
{...menuProps?.(card)}
|
||||||
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface disabled:opacity-60 enabled:hover:border-accent transition-colors"
|
// `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">
|
<span className="block aspect-video bg-card overflow-hidden">
|
||||||
{card.thumb ? (
|
{card.thumb ? (
|
||||||
@@ -163,6 +257,9 @@ function Hero({
|
|||||||
rerolling: boolean;
|
rerolling: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 started = card.position_seconds > 0;
|
||||||
const left = card.remaining_seconds;
|
const left = card.remaining_seconds;
|
||||||
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
|
// 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");
|
: t("home.hero.surprise");
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPlay(card)}
|
onClick={() => onPlay(card)}
|
||||||
@@ -222,6 +322,7 @@ function Hero({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => c.title} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -344,6 +445,40 @@ export default function Home() {
|
|||||||
[qc, t],
|
[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(() => {
|
const reroll = useCallback(() => {
|
||||||
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
|
// 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
|
// 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}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onToggleWatched={onToggleWatched}
|
onToggleWatched={onToggleWatched}
|
||||||
|
onMove={moveInShelf}
|
||||||
/>
|
/>
|
||||||
<Rail
|
<Rail
|
||||||
title={t("home.continue.title")}
|
title={t("home.continue.title")}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { applyView, sameOrder } from "../lib/playlistView";
|
|||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
|
import { useComfortPin, pinVideo } from "./comfortPin";
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import UndoToolbar from "./ui/UndoToolbar";
|
import UndoToolbar from "./ui/UndoToolbar";
|
||||||
@@ -220,6 +221,9 @@ export default function Playlists() {
|
|||||||
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
||||||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||||||
const rowMenu = useContextMenu<Video>();
|
const rowMenu = useContextMenu<Video>();
|
||||||
|
// The comfort-shelf pin composes into the row's existing menu (E24 S3) rather than mounting a
|
||||||
|
// second one on the same <tr>.
|
||||||
|
const comfortPin = useComfortPin(rowMenu.open);
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: qk.playlists(),
|
queryKey: qk.playlists(),
|
||||||
@@ -613,6 +617,7 @@ export default function Playlists() {
|
|||||||
icon: <Play className="w-4 h-4" />,
|
icon: <Play className="w-4 h-4" />,
|
||||||
onSelect: () => setPlayingId(ctxRow.id),
|
onSelect: () => setPlayingId(ctxRow.id),
|
||||||
},
|
},
|
||||||
|
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
|
||||||
...(canEdit
|
...(canEdit
|
||||||
? ([
|
? ([
|
||||||
moveTargets.length
|
moveTargets.length
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { type PanelLayout } from "../lib/panelLayout";
|
|||||||
import FilterRail from "./FilterRail";
|
import FilterRail from "./FilterRail";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
|
import { useComfortPin, pinPlaylist } from "./comfortPin";
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { type PanelItem } from "./ui/PanelGroups";
|
import { type PanelItem } from "./ui/PanelGroups";
|
||||||
@@ -74,6 +75,9 @@ export default function PlaylistsRail({
|
|||||||
const plActions = usePlaylistActions();
|
const plActions = usePlaylistActions();
|
||||||
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
|
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
|
||||||
const railMenu = useContextMenu<Playlist>();
|
const railMenu = useContextMenu<Playlist>();
|
||||||
|
// The comfort-shelf pin composes INTO this menu rather than mounting a second one: a row can only
|
||||||
|
// have one right-click menu, and rename/delete/pin are all things you do to the same playlist.
|
||||||
|
const comfortPin = useComfortPin(railMenu.open);
|
||||||
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
|
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
|
||||||
// context menu is that the action lands where the gesture did.
|
// context menu is that the action lands where the gesture did.
|
||||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||||
@@ -226,6 +230,7 @@ export default function PlaylistsRail({
|
|||||||
setRenamingId(ctxPl.id);
|
setRenamingId(ctxPl.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
|
||||||
{
|
{
|
||||||
id: "delete",
|
id: "delete",
|
||||||
label: t("playlists.delete"),
|
label: t("playlists.delete"),
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilter
|
|||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
import { usePublishPlexView } from "./PlexViewBridge";
|
import { usePublishPlexView } from "./PlexViewBridge";
|
||||||
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
||||||
|
import { PlexPinMenu } from "./ComfortPinMenu";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||||
|
|
||||||
@@ -172,6 +174,8 @@ export default function PlexBrowse() {
|
|||||||
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
|
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
|
||||||
// different title's info starts at the top.
|
// different title's info starts at the top.
|
||||||
const infoScrollRef = useRef(0);
|
const infoScrollRef = useRef(0);
|
||||||
|
// One comfort-pin menu for the titles grid (see ui/ContextMenu).
|
||||||
|
const pinMenu = useContextMenu<PlexCard>();
|
||||||
const scroller = () => document.querySelector("main");
|
const scroller = () => document.querySelector("main");
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scroller();
|
const el = scroller();
|
||||||
@@ -606,6 +610,7 @@ export default function PlexBrowse() {
|
|||||||
onOpen={() => onCard(c)}
|
onOpen={() => onCard(c)}
|
||||||
onInfo={() => onInfo(c)}
|
onInfo={() => onInfo(c)}
|
||||||
onToggleWatched={toggleWatched}
|
onToggleWatched={toggleWatched}
|
||||||
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -631,6 +636,7 @@ export default function PlexBrowse() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PlexPinMenu menu={pinMenu} />
|
||||||
<div ref={setSentinelEl} className="h-8" />
|
<div ref={setSentinelEl} className="h-8" />
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { showEpisodeKeys } from "../lib/plexQueue";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
|
import { PlexPinMenu } from "./ComfortPinMenu";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
@@ -47,6 +49,8 @@ export default function PlexCollectionView({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
// One comfort-pin menu for the member grid (see ui/ContextMenu).
|
||||||
|
const pinMenu = useContextMenu<PlexCard>();
|
||||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: qk.plexCollections("detail", collectionId),
|
queryKey: qk.plexCollections("detail", collectionId),
|
||||||
@@ -348,6 +352,7 @@ export default function PlexCollectionView({
|
|||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
onInfo={() => onInfoCard(c)}
|
onInfo={() => onInfoCard(c)}
|
||||||
onToggleWatched={toggleWatched}
|
onToggleWatched={toggleWatched}
|
||||||
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
@@ -366,6 +371,7 @@ export default function PlexCollectionView({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<PlexPinMenu menu={pinMenu} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { toggleWatchState } from "../lib/plexWatch";
|
|||||||
import { usePlex } from "./PlexProvider";
|
import { usePlex } from "./PlexProvider";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
|
import { PlexPinMenu } from "./ComfortPinMenu";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
|
|
||||||
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
||||||
@@ -40,6 +42,8 @@ function CardRail({
|
|||||||
onToggleWatched: (c: PlexCard) => void;
|
onToggleWatched: (c: PlexCard) => void;
|
||||||
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
|
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
|
||||||
}) {
|
}) {
|
||||||
|
// One comfort-pin menu per rail (see ui/ContextMenu), not one per poster.
|
||||||
|
const pinMenu = useContextMenu<PlexCard>();
|
||||||
const heading =
|
const heading =
|
||||||
action != null ? (
|
action != null ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
@@ -59,10 +63,12 @@ function CardRail({
|
|||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
onInfo={() => onInfoCard(c)}
|
onInfo={() => onInfoCard(c)}
|
||||||
onToggleWatched={onToggleWatched}
|
onToggleWatched={onToggleWatched}
|
||||||
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
<PlexPinMenu menu={pinMenu} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ function VideoCard({
|
|||||||
onResetState,
|
onResetState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
menuProps,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
@@ -394,6 +395,12 @@ function VideoCard({
|
|||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
|
/** Right-click / long-press props for the LIST's one context menu (E24 S3 — the comfort pin).
|
||||||
|
* A FUNCTION, not the already-built props object: `useContextMenu.triggerProps` is stable across
|
||||||
|
* renders while its return value is a fresh object every call, and a fresh object prop would
|
||||||
|
* blow past this component's memo for every card in the virtualized feed on every parent
|
||||||
|
* render. The card calls it with the video it already has. */
|
||||||
|
menuProps?: (v: Video) => Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { openChannel } = useNavigationActions();
|
const { openChannel } = useNavigationActions();
|
||||||
@@ -521,6 +528,7 @@ function VideoCard({
|
|||||||
<div
|
<div
|
||||||
data-testid="video-card"
|
data-testid="video-card"
|
||||||
data-video-id={video.id}
|
data-video-id={video.id}
|
||||||
|
{...menuProps?.(video)}
|
||||||
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
||||||
>
|
>
|
||||||
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
||||||
@@ -583,6 +591,7 @@ function VideoCard({
|
|||||||
<div
|
<div
|
||||||
data-testid="video-card"
|
data-testid="video-card"
|
||||||
data-video-id={video.id}
|
data-video-id={video.id}
|
||||||
|
{...menuProps?.(video)}
|
||||||
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
|
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
|
||||||
>
|
>
|
||||||
<Thumb
|
<Thumb
|
||||||
@@ -616,6 +625,7 @@ function VideoCard({
|
|||||||
<div
|
<div
|
||||||
data-testid="video-card"
|
data-testid="video-card"
|
||||||
data-video-id={video.id}
|
data-video-id={video.id}
|
||||||
|
{...menuProps?.(video)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
|
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
|
||||||
"flex flex-col",
|
"flex flex-col",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Video, VideoStatus } from "../lib/api";
|
import type { Video, VideoStatus } from "../lib/api";
|
||||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
|
import { VideoPinMenu } from "./ComfortPinMenu";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import VirtualGrid from "./ui/VirtualGrid";
|
import VirtualGrid from "./ui/VirtualGrid";
|
||||||
|
|
||||||
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
||||||
@@ -45,31 +47,40 @@ export default function VirtualFeed({
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
}: VirtualFeedProps) {
|
}: VirtualFeedProps) {
|
||||||
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
|
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
|
||||||
|
// The comfort-shelf pin (E24 S3) lives HERE rather than in each of the feed's callers: this is the
|
||||||
|
// list, and `useContextMenu`'s contract is one menu per list. Mounting it at this level is also
|
||||||
|
// what gives the channel page the gesture for free — it renders `<Feed channelScoped>`, so it is
|
||||||
|
// the same component all the way down.
|
||||||
|
const pinMenu = useContextMenu<Video>();
|
||||||
return (
|
return (
|
||||||
<VirtualGrid
|
<>
|
||||||
items={items}
|
<VirtualGrid
|
||||||
getKey={(v) => v.id}
|
items={items}
|
||||||
minCol={minCol}
|
getKey={(v) => v.id}
|
||||||
colMaxWidth={colMaxWidth}
|
minCol={minCol}
|
||||||
maxCols={maxCols}
|
colMaxWidth={colMaxWidth}
|
||||||
rowEst={ROW_EST[view]}
|
maxCols={maxCols}
|
||||||
hasNextPage={hasNextPage}
|
rowEst={ROW_EST[view]}
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
hasNextPage={hasNextPage}
|
||||||
fetchNextPage={fetchNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
renderItem={(v, itemWidth) => (
|
fetchNextPage={fetchNextPage}
|
||||||
<VideoCard
|
renderItem={(v, itemWidth) => (
|
||||||
video={v}
|
<VideoCard
|
||||||
view={view}
|
video={v}
|
||||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
view={view}
|
||||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||||
width={thumb ? 0 : itemWidth}
|
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||||
onState={onState}
|
width={thumb ? 0 : itemWidth}
|
||||||
onToggleSave={onToggleSave}
|
onState={onState}
|
||||||
onResetState={onResetState}
|
onToggleSave={onToggleSave}
|
||||||
onOpen={onOpen}
|
onResetState={onResetState}
|
||||||
/>
|
onOpen={onOpen}
|
||||||
)}
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<VideoPinMenu menu={pinMenu} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -51,11 +51,16 @@ export function PlexPosterCard({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onInfo,
|
onInfo,
|
||||||
onToggleWatched,
|
onToggleWatched,
|
||||||
|
menuProps,
|
||||||
}: {
|
}: {
|
||||||
card: PlexCard;
|
card: PlexCard;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onInfo: () => void;
|
onInfo: () => void;
|
||||||
onToggleWatched: (c: PlexCard) => void;
|
onToggleWatched: (c: PlexCard) => void;
|
||||||
|
/** Right-click / long-press props for the GRID's one context menu (E24 S3 — the comfort pin).
|
||||||
|
* A function of the card rather than a ready-made object, so every card surface in the app takes
|
||||||
|
* `menuProps={menu.triggerProps}` and reads the same at every call site. */
|
||||||
|
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||||
@@ -69,6 +74,7 @@ export function PlexPosterCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group text-left"
|
className="group text-left"
|
||||||
|
{...menuProps?.(card)}
|
||||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
WatchedToggle,
|
WatchedToggle,
|
||||||
} from "../lib/plexDetailUi";
|
} from "../lib/plexDetailUi";
|
||||||
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||||
|
import { PlexPinMenu } from "./ComfortPinMenu";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
@@ -106,6 +108,10 @@ export function PlexPersonView({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
// ONE comfort-pin menu for this whole page — the "known for" rail and the two filmography grids
|
||||||
|
// are three lists on one screen, and the popover anchors at the pointer, so a second controller
|
||||||
|
// would only mean a second closed popover to keep shut (see ui/ContextMenu).
|
||||||
|
const pinMenu = useContextMenu<PlexCard>();
|
||||||
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
|
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
|
||||||
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
|
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
|
||||||
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
|
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
|
||||||
@@ -153,6 +159,7 @@ export function PlexPersonView({
|
|||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
onInfo={() => onInfoCard(c)}
|
onInfo={() => onInfoCard(c)}
|
||||||
onToggleWatched={toggle}
|
onToggleWatched={toggle}
|
||||||
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +249,7 @@ export function PlexPersonView({
|
|||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
onInfo={() => onInfoCard(c)}
|
onInfo={() => onInfoCard(c)}
|
||||||
onToggleWatched={toggle}
|
onToggleWatched={toggle}
|
||||||
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -259,6 +267,7 @@ export function PlexPersonView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PlexPinMenu menu={pinMenu} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function DataTable<T>({
|
|||||||
persistKey,
|
persistKey,
|
||||||
emptyText,
|
emptyText,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
|
rowProps,
|
||||||
controlsPosition = "bottom",
|
controlsPosition = "bottom",
|
||||||
controlsLeading,
|
controlsLeading,
|
||||||
controlsInBand = false,
|
controlsInBand = false,
|
||||||
@@ -70,6 +71,12 @@ export default function DataTable<T>({
|
|||||||
persistKey?: string;
|
persistKey?: string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
rowClassName?: (row: T) => string;
|
rowClassName?: (row: T) => string;
|
||||||
|
// Extra DOM props per row — the escape hatch a caller needs to hang an INPUT PATH on a row it does
|
||||||
|
// not own the markup of. Today's only consumer is `useContextMenu().triggerProps` (right-click /
|
||||||
|
// long-press / Shift+F10), and both row shapes get it, so the wide table and the narrow card
|
||||||
|
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
|
||||||
|
// and never learns what a menu is.
|
||||||
|
rowProps?: (row: T) => Record<string, unknown>;
|
||||||
controlsPosition?: "top" | "bottom" | "both";
|
controlsPosition?: "top" | "bottom" | "both";
|
||||||
controlsLeading?: ReactNode;
|
controlsLeading?: ReactNode;
|
||||||
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
|
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
|
||||||
@@ -253,6 +260,7 @@ export default function DataTable<T>({
|
|||||||
{paged.map((row) => (
|
{paged.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
|
{...rowProps?.(row)}
|
||||||
className={`border-b border-border/50 hover:bg-(--hover) transition ${
|
className={`border-b border-border/50 hover:bg-(--hover) transition ${
|
||||||
rowClassName?.(row) ?? ""
|
rowClassName?.(row) ?? ""
|
||||||
}`}
|
}`}
|
||||||
@@ -278,6 +286,7 @@ export default function DataTable<T>({
|
|||||||
{paged.map((row) => (
|
{paged.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
|
{...rowProps?.(row)}
|
||||||
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
|
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
|
||||||
>
|
>
|
||||||
{columns
|
{columns
|
||||||
|
|||||||
@@ -32,6 +32,20 @@
|
|||||||
"unwatchedCount_one": "{{count}} unwatched",
|
"unwatchedCount_one": "{{count}} unwatched",
|
||||||
"unwatchedCount_other": "{{count}} unwatched",
|
"unwatchedCount_other": "{{count}} unwatched",
|
||||||
"itemCount_one": "{{count}} video",
|
"itemCount_one": "{{count}} video",
|
||||||
"itemCount_other": "{{count}} videos"
|
"itemCount_other": "{{count}} videos",
|
||||||
|
"moveFirst": "To the front",
|
||||||
|
"moveLeft": "Left",
|
||||||
|
"moveRight": "Right",
|
||||||
|
"moveLast": "To the end",
|
||||||
|
"moveFailed": "Could not reorder the shelf."
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"menu": "Comfort shelf",
|
||||||
|
"add": "Pin to comfort shelf",
|
||||||
|
"remove": "Remove from shelf",
|
||||||
|
"added": "{{title}} is on your comfort shelf.",
|
||||||
|
"removed": "{{title}} is off your comfort shelf.",
|
||||||
|
"addFailed": "Could not pin it to the shelf.",
|
||||||
|
"removeFailed": "Could not remove it from the shelf."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,20 @@
|
|||||||
"unwatchedCount_one": "{{count}} megnézetlen",
|
"unwatchedCount_one": "{{count}} megnézetlen",
|
||||||
"unwatchedCount_other": "{{count}} megnézetlen",
|
"unwatchedCount_other": "{{count}} megnézetlen",
|
||||||
"itemCount_one": "{{count}} videó",
|
"itemCount_one": "{{count}} videó",
|
||||||
"itemCount_other": "{{count}} videó"
|
"itemCount_other": "{{count}} videó",
|
||||||
|
"moveFirst": "A polc elejére",
|
||||||
|
"moveLeft": "Balra",
|
||||||
|
"moveRight": "Jobbra",
|
||||||
|
"moveLast": "A polc végére",
|
||||||
|
"moveFailed": "Nem sikerült átrendezni a polcot."
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"menu": "Komfort polc",
|
||||||
|
"add": "Komfort polcra",
|
||||||
|
"remove": "Levétel a polcról",
|
||||||
|
"added": "{{title}} felkerült a komfort polcra.",
|
||||||
|
"removed": "{{title}} lekerült a komfort polcról.",
|
||||||
|
"addFailed": "Nem sikerült a polcra tűzni.",
|
||||||
|
"removeFailed": "Nem sikerült levenni a polcról."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user