The card menu's reset read 'Clear watch progress' but also un-hides and un-watches; it now says so. The resettable rule, the demo-first role key and the display-name fallback each lived in several components tied by comments; they are shared helpers now, and the menu reuses the card's own strings instead of duplicate keys.
110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
|
|
|
|
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
|
|
import { isResettable } from "../lib/api/feed";
|
|
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
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 feed's menu (the grid and a channel page): the card's own state actions — the hover row's
|
|
* buttons, which a keyboard or a touch user otherwise reaches only by Tabbing through every card —
|
|
* 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.markWatched"),
|
|
icon: <Check className={icon} />,
|
|
onSelect: () => onState(v.id, "watched"),
|
|
},
|
|
{
|
|
id: "save",
|
|
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
|
|
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
|
onSelect: () => onToggleSave(v.id, !v.saved),
|
|
},
|
|
{
|
|
id: "hide",
|
|
label: v.status === "hidden" ? t("card.unhide") : t("card.hide"),
|
|
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
|
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"),
|
|
},
|
|
...(isResettable(v)
|
|
? [
|
|
{
|
|
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} />;
|
|
}
|