Files
siftlode/frontend/src/components/ComfortPinMenu.tsx
T
peter 92979fa920 refactor(ui): one rule for resettable, role key and display name; an honest reset label
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.
2026-09-19 05:47:17 +02:00

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} />;
}