feat(feed): the card menu carries the card's own actions
Right-click, long-press or Shift+F10 on a feed card offered only the comfort-shelf pin; watched, saved, hidden and the progress reset were reachable only by hovering or Tabbing through every card.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
|
||||||
|
|
||||||
import { type PlexCard, type Video } from "../lib/api";
|
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
|
||||||
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
|
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
|
||||||
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { type ContextMenuController } from "./ui/useContextMenu";
|
import { type ContextMenuController } from "./ui/useContextMenu";
|
||||||
|
|
||||||
@@ -44,7 +46,64 @@ export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> })
|
|||||||
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
|
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The YouTube video lists (the feed grid, the Home rails). */
|
/** The feed's menu (the grid and a channel page): the card's own state actions — the hover row's
|
||||||
export function VideoPinMenu({ menu }: { menu: ContextMenuController<Video> }) {
|
* buttons, which a keyboard or a touch user otherwise reaches only by Tabbing through every card —
|
||||||
return <ComfortPinMenu menu={menu} target={(v) => pinVideo(v.id)} title={(v) => v.title ?? ""} />;
|
* then the pin. Playlists and downloads stay on the card: each opens its own anchored dialog. */
|
||||||
|
export function FeedVideoMenu({
|
||||||
|
menu,
|
||||||
|
onState,
|
||||||
|
onToggleSave,
|
||||||
|
onResetState,
|
||||||
|
}: {
|
||||||
|
menu: ContextMenuController<Video>;
|
||||||
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
|
onResetState: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pin = useComfortPin(menu.open);
|
||||||
|
const v = menu.target;
|
||||||
|
const icon = "w-4 h-4";
|
||||||
|
const items: ActionMenuItem[] = v
|
||||||
|
? [
|
||||||
|
v.status === "watched"
|
||||||
|
? {
|
||||||
|
id: "watched",
|
||||||
|
label: t("card.menu.unmarkWatched"),
|
||||||
|
icon: <Undo2 className={icon} />,
|
||||||
|
onSelect: () => onState(v.id, "new"),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: "watched",
|
||||||
|
label: t("card.menu.markWatched"),
|
||||||
|
icon: <Check className={icon} />,
|
||||||
|
onSelect: () => onState(v.id, "watched"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "save",
|
||||||
|
label: v.saved ? t("card.menu.unsave") : t("card.menu.save"),
|
||||||
|
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
||||||
|
onSelect: () => onToggleSave(v.id, !v.saved),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hide",
|
||||||
|
label: v.status === "hidden" ? t("card.menu.unhide") : t("card.menu.hide"),
|
||||||
|
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
||||||
|
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"),
|
||||||
|
},
|
||||||
|
// The card offers the reset only on a card that has something to reset; so does this.
|
||||||
|
...(v.status !== "new" || v.position_seconds > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "reset",
|
||||||
|
label: t("card.menu.reset"),
|
||||||
|
icon: <RotateCcw className={icon} />,
|
||||||
|
onSelect: () => onResetState(v.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...pin.item(pinVideo(v.id), v.title ?? ""),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
return <ContextMenu menu={menu} ariaLabel={t("card.menu.label")} items={items} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The feed card's right-click menu (prod UAT 2026-09-19): it carried only the comfort-shelf pin, so
|
||||||
|
// the card's own state actions — watched, saved, hidden, reset — were reachable only by hovering or
|
||||||
|
// by Tabbing through every card. Each row fires the same handler the card's button does.
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { Video } from "../lib/api";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||||
|
return { ...actual, api: { ...actual.api, comfortPins: () => Promise.resolve({ items: [] }) } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { FeedVideoMenu } from "./ComfortPinMenu";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
|
||||||
|
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
|
||||||
|
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||||
|
const item = (label: string) => {
|
||||||
|
const found = screen
|
||||||
|
.queryAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === label);
|
||||||
|
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
const video = (extra: Partial<Video>): Video =>
|
||||||
|
({
|
||||||
|
id: "v1",
|
||||||
|
title: "A video",
|
||||||
|
status: "new",
|
||||||
|
saved: false,
|
||||||
|
position_seconds: 0,
|
||||||
|
...extra,
|
||||||
|
}) as Video;
|
||||||
|
|
||||||
|
type Handlers = {
|
||||||
|
onState: (id: string, status: string) => void;
|
||||||
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
|
onResetState: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Feed({ v, handlers }: { v: Video; handlers: Handlers }) {
|
||||||
|
const menu = useContextMenu<Video>();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div {...menu.triggerProps(v)}>card</div>
|
||||||
|
<FeedVideoMenu
|
||||||
|
menu={menu}
|
||||||
|
onState={handlers.onState}
|
||||||
|
onToggleSave={handlers.onToggleSave}
|
||||||
|
onResetState={handlers.onResetState}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu(v: Video) {
|
||||||
|
const handlers = { onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() };
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<FloatingTree>
|
||||||
|
<Feed v={v} handlers={handlers} />
|
||||||
|
</FloatingTree>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.contextMenu(screen.getByText("card"));
|
||||||
|
return { handlers };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the feed card's menu", () => {
|
||||||
|
it("offers the card's own actions on a fresh video, and each fires the card's handler", async () => {
|
||||||
|
const { handlers } = openMenu(video({}));
|
||||||
|
expect(labels()).toEqual([
|
||||||
|
"card.menu.markWatched",
|
||||||
|
"card.menu.save",
|
||||||
|
"card.menu.hide",
|
||||||
|
"home.pin.add",
|
||||||
|
]);
|
||||||
|
fireEvent.click(item("card.menu.markWatched"));
|
||||||
|
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the way back on a watched, saved one — and the reset", async () => {
|
||||||
|
const { handlers } = openMenu(video({ status: "watched", saved: true }));
|
||||||
|
expect(labels()).toEqual([
|
||||||
|
"card.menu.unmarkWatched",
|
||||||
|
"card.menu.unsave",
|
||||||
|
"card.menu.hide",
|
||||||
|
"card.menu.reset",
|
||||||
|
"home.pin.add",
|
||||||
|
]);
|
||||||
|
fireEvent.click(item("card.menu.unsave"));
|
||||||
|
await vi.waitFor(() => expect(handlers.onToggleSave).toHaveBeenLastCalledWith("v1", false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unhides a hidden one", async () => {
|
||||||
|
const { handlers } = openMenu(video({ status: "hidden", position_seconds: 30 }));
|
||||||
|
fireEvent.click(item("card.menu.unhide"));
|
||||||
|
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the progress of a half-watched one", async () => {
|
||||||
|
const { handlers } = openMenu(video({ position_seconds: 30 }));
|
||||||
|
fireEvent.click(item("card.menu.reset"));
|
||||||
|
await vi.waitFor(() => expect(handlers.onResetState).toHaveBeenLastCalledWith("v1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { FeedVideoMenu } from "./ComfortPinMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import VirtualGrid from "./ui/VirtualGrid";
|
import VirtualGrid from "./ui/VirtualGrid";
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ 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
|
// The card menu (its state actions and the comfort-shelf pin, E24 S3) lives HERE rather than in each
|
||||||
// list, and `useContextMenu`'s contract is one menu per list. Mounting it at this level is also
|
// 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
|
// what gives the channel page the gesture for free — it renders `<Feed channelScoped>`, so it is
|
||||||
// the same component all the way down.
|
// the same component all the way down.
|
||||||
const pinMenu = useContextMenu<Video>();
|
const pinMenu = useContextMenu<Video>();
|
||||||
@@ -82,7 +82,12 @@ export default function VirtualFeed({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<VideoPinMenu menu={pinMenu} />
|
<FeedVideoMenu
|
||||||
|
menu={pinMenu}
|
||||||
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
|
onResetState={onResetState}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,15 @@
|
|||||||
"stream": "stream",
|
"stream": "stream",
|
||||||
"views": "views",
|
"views": "views",
|
||||||
"info": "Details",
|
"info": "Details",
|
||||||
"noDescription": "No description"
|
"noDescription": "No description",
|
||||||
|
"menu": {
|
||||||
|
"label": "Video",
|
||||||
|
"markWatched": "Mark watched",
|
||||||
|
"unmarkWatched": "Mark unwatched",
|
||||||
|
"save": "Save for later",
|
||||||
|
"unsave": "Remove from saved",
|
||||||
|
"hide": "Hide",
|
||||||
|
"unhide": "Unhide",
|
||||||
|
"reset": "Clear watch progress"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,15 @@
|
|||||||
"stream": "stream",
|
"stream": "stream",
|
||||||
"views": "megtekintés",
|
"views": "megtekintés",
|
||||||
"info": "Részletek",
|
"info": "Részletek",
|
||||||
"noDescription": "Nincs leírás"
|
"noDescription": "Nincs leírás",
|
||||||
|
"menu": {
|
||||||
|
"label": "Videó",
|
||||||
|
"markWatched": "Megnézettnek jelöl",
|
||||||
|
"unmarkWatched": "Megnézés visszavonása",
|
||||||
|
"save": "Mentés későbbre",
|
||||||
|
"unsave": "Törlés a mentettek közül",
|
||||||
|
"hide": "Elrejtés",
|
||||||
|
"unhide": "Megjelenítés",
|
||||||
|
"reset": "Nézési előzmény törlése"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user