"Add to collection…" opens a panel beside the card: the title's library's manual collections tick it in or out, and a name + Enter creates a new one with the title in it. It used to take the Collections page, a new collection, its name and a title search, then another search per title. A lean endpoint lists the collections with whether each holds the title. Admin only, as on the title's page. Also: a collection seeded with a show is made as a show collection (Plex type 2). Measured: as a movie one it came out empty and refused the show after.
161 lines
6.6 KiB
TypeScript
161 lines
6.6 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
|
|
|
|
import { type PlexCard } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
|
|
import { pinPlex, useComfortPin } from "./comfortPin";
|
|
import { useCopyItems } from "./copyItems";
|
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
|
|
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
|
|
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
|
|
// lists the same actions in the same order. The card keeps its state (watched, progress) at rest and
|
|
// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
|
|
|
|
export interface PlexActionHandlers {
|
|
onOpen: (c: PlexCard) => void;
|
|
/** The media-info page. Omitted where a surface has none (the episode lists). */
|
|
onInfo?: (c: PlexCard) => void;
|
|
onToggleWatched?: (c: PlexCard) => void;
|
|
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
|
|
* under Play — on touch, the only way to it (the card's hover pair is mouse-only). */
|
|
onRestart?: (c: PlexCard) => void;
|
|
}
|
|
|
|
const icon = "w-4 h-4";
|
|
|
|
/** The "add to playlist" row and button: an episode says which it adds, as the season page does. */
|
|
export const plexAddLabelKey = (c: PlexCard): string =>
|
|
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage";
|
|
|
|
/** A show opens its drill-down; a movie or an episode plays. */
|
|
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
|
|
|
|
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
|
|
* stored at 0, so it never counts. */
|
|
export const plexStarted = (c: PlexCard): boolean =>
|
|
isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0;
|
|
|
|
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
|
|
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
|
|
* (one per list, so a card does not mount a modal of its own). */
|
|
export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) {
|
|
const { t } = useTranslation();
|
|
const pin = useComfortPin(open);
|
|
const copy = useCopyItems();
|
|
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
|
|
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
|
|
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(null);
|
|
// Collections are shared and written to Plex itself, so only an admin edits them (as on the title's
|
|
// own page).
|
|
const qc = useQueryClient();
|
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
|
// A whole show needs its episode keys, which only the show page has ("Add whole show" there), so
|
|
// only a leaf is added from a card.
|
|
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
|
|
|
|
/** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`);
|
|
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind;
|
|
* `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside —
|
|
* without one the row is left out rather than opening the panel at a corner. */
|
|
const items = (
|
|
c: PlexCard,
|
|
{
|
|
play = true,
|
|
pin: withPin = true,
|
|
anchor = null,
|
|
}: { play?: boolean; pin?: boolean; anchor?: Element | null } = {},
|
|
): ActionMenuItem[] => {
|
|
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
|
const playable = isPlexPlayable(c);
|
|
const watched = c.status === "watched";
|
|
const started = plexStarted(c);
|
|
return [
|
|
...(play
|
|
? [
|
|
{
|
|
id: "play",
|
|
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
|
|
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />,
|
|
onSelect: () => onOpen(c),
|
|
},
|
|
// The same words and glyph as the video card's menu (useVideoActions).
|
|
...(started && onRestart
|
|
? [
|
|
{
|
|
id: "restart",
|
|
label: t("card.restartTitle"),
|
|
icon: <SkipBack className={icon} />,
|
|
onSelect: () => onRestart(c),
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
: []),
|
|
...(playable && onToggleWatched
|
|
? [
|
|
{
|
|
id: "watched",
|
|
label: watched ? t("plex.markUnwatched") : t("plex.markWatched"),
|
|
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
|
|
separatorBefore: true,
|
|
onSelect: () => onToggleWatched(c),
|
|
},
|
|
]
|
|
: []),
|
|
...(playable
|
|
? [
|
|
{
|
|
id: "playlist",
|
|
label: addLabel(c),
|
|
icon: <ListPlus className={icon} />,
|
|
separatorBefore: !onToggleWatched,
|
|
onSelect: () => add(c),
|
|
},
|
|
]
|
|
: []),
|
|
// A movie or a show can be a collection member; an episode cannot.
|
|
...(isAdmin && anchor && c.type !== "episode"
|
|
? [
|
|
{
|
|
id: "collection",
|
|
label: t("plex.collectionAdd.row"),
|
|
icon: <Layers className={icon} />,
|
|
separatorBefore: !playable,
|
|
onSelect: () => setCollecting({ ratingKey: c.id, anchor }),
|
|
},
|
|
]
|
|
: []),
|
|
...(playable && onInfo
|
|
? [
|
|
{
|
|
id: "details",
|
|
label: t("plex.info.title"),
|
|
icon: <Info className={icon} />,
|
|
separatorBefore: true,
|
|
onSelect: () => onInfo(c),
|
|
},
|
|
]
|
|
: []),
|
|
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !(playable && onInfo) })),
|
|
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
|
];
|
|
};
|
|
|
|
const panel = (
|
|
<>
|
|
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
|
|
{collecting && (
|
|
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
|
|
)}
|
|
</>
|
|
);
|
|
return { items, add, panel };
|
|
}
|
|
|
|
export type PlexCardActions = ReturnType<typeof usePlexCardActions>;
|