feat(design): a Plex title's one action list (C1) — the poster's "…", details and the pin in its menu (E28 S3e2)

Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails)
now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…":
play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions +
PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows
watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video
card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill
stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover
row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the
card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's
admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two
columns on a phone. The unused .reveal-idle rule is gone.
This commit is contained in:
2026-09-26 01:51:53 +02:00
parent 75ae810866
commit a4737b92e4
17 changed files with 1003 additions and 465 deletions
+116 -27
View File
@@ -1,9 +1,26 @@
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react";
import {
ArrowLeft,
Check,
Layers,
ListPlus,
Play,
RotateCcw,
Star,
Tv2,
Undo2,
User,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api";
import {
api,
type PlexCard,
type PlexFilters,
type PlexPersonDetail,
type PlexSeasonDetail,
} from "../lib/api";
import { plexLang } from "../i18n";
import { pickRatingBadge } from "../lib/plexRating";
import { notifyActionError } from "../lib/actionErrors";
@@ -19,7 +36,10 @@ import {
} from "./plexDetailUi";
import { epLabel, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { PlexMenu } from "./PlexMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { useCopyItems } from "./copyItems";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
@@ -110,10 +130,10 @@ export function PlexPersonView({
}) {
const { t } = useTranslation();
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>();
// ONE menu for this whole page — the "known for" rail and the two filmography grids are three
// lists on one screen, and the popover anchors where it opened, so a second controller would only
// mean a second closed popover to keep shut (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
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
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
@@ -149,6 +169,17 @@ export function PlexPersonView({
},
});
const actions = usePlexCardActions(
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggle },
menu.open,
);
const cardMenu = (c: PlexCard) => ({
onAdd: actions.add,
menuProps: menu.triggerProps,
menuButtonProps: menu.buttonProps,
menuOpen: menu.open && menu.target === c,
});
const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-5">
@@ -160,9 +191,8 @@ export function PlexPersonView({
navIndex={i}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
menuProps={pinMenu.triggerProps}
{...cardMenu(c)}
/>
))}
</NavGrid>
@@ -252,9 +282,8 @@ export function PlexPersonView({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
menuProps={pinMenu.triggerProps}
{...cardMenu(c)}
/>
</div>
))}
@@ -272,7 +301,7 @@ export function PlexPersonView({
)}
</div>
)}
<PlexPinMenu menu={pinMenu} />
<PlexMenu menu={menu} actions={actions} />
</div>
);
}
@@ -313,7 +342,10 @@ export function PlexShowView({
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false);
const relatedPin = useContextMenu<PlexCard>();
const relatedMenu = useContextMenu<PlexCard>();
const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open);
const seasonMenu = useContextMenu<PlexSeasonDetail>();
const copy = useCopyItems();
const show = d?.show;
const showLib = show?.library ?? undefined;
@@ -359,6 +391,49 @@ export function PlexShowView({
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// A season's add target (none for an empty season, which has nothing to add).
const addSeason = (se: PlexSeasonDetail) =>
show && se.episodes.length > 0
? () =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${show.title} — ${se.title}`,
})
: undefined;
// A season's one action list (E28 C1): the rows its card shows on hover, plus copy.
const seasonItems = (se: PlexSeasonDetail) => {
const seWatched = se.status === "watched";
const add = addSeason(se);
return [
{
id: "open",
label: t("plex.series.openSeason"),
icon: <Tv2 className="w-4 h-4" />,
onSelect: () => onOpenSeason(se.id),
},
{
id: "watched",
label: seWatched
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched"),
icon: seWatched ? <Undo2 className="w-4 h-4" /> : <Check className="w-4 h-4" />,
separatorBefore: true,
onSelect: () => void markSeason(se.id, !seWatched),
},
...(add
? [
{
id: "playlist",
label: t("plex.playlist.addSeason"),
icon: <ListPlus className="w-4 h-4" />,
onSelect: add,
},
]
: []),
...copy(se.title, null).map((it) => ({ ...it, separatorBefore: true })),
];
};
return (
<div className="p-4 max-w-300 mx-auto glass-media">
@@ -522,16 +597,10 @@ export function PlexShowView({
se={se}
onOpen={() => onOpenSeason(se.id)}
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
onAdd={
se.episodes.length > 0
? () =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${show.title} — ${se.title}`,
})
: undefined
}
onAdd={addSeason(se)}
menuProps={seasonMenu.triggerProps}
menuButtonProps={seasonMenu.buttonProps}
menuOpen={seasonMenu.open && seasonMenu.target === se}
/>
))}
</NavGrid>
@@ -543,13 +612,20 @@ export function PlexShowView({
{d.related.length > 0 && showRelated && (
<RelatedStrip
related={d.related}
onOpen={onOpenShow}
menuProps={relatedPin.triggerProps}
onOpen={(c) => onOpenShow(c.id)}
menuProps={relatedMenu.triggerProps}
menuButtonProps={relatedMenu.buttonProps}
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
/>
)}
</>
)}
<PlexPinMenu menu={relatedPin} />
<PlexMenu menu={relatedMenu} actions={related} />
<ContextMenu
menu={seasonMenu}
ariaLabel={t("plex.menu")}
items={seasonMenu.target ? seasonItems(seasonMenu.target) : []}
/>
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
{collOpen && show && showLib && (
@@ -585,6 +661,7 @@ export function PlexSeasonView({
});
const d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId);
const epMenu = useContextMenu<PlexCard>();
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [busy, setBusy] = useState(false);
@@ -621,6 +698,14 @@ export function PlexSeasonView({
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// The episode grid's one menu (an episode is not a comfort-shelf kind, so no pin).
const episodes = usePlexCardActions(
{
onOpen: (ep) => onPlay(ep.id, allKeys),
onToggleWatched: (ep) => void markEpisode(ep),
},
epMenu.open,
);
return (
<div className="p-4 max-w-300 mx-auto glass-media">
@@ -707,8 +792,11 @@ export function PlexSeasonView({
navIndex={i}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onAdd={() => episodes.add(ep)}
onToggleWatched={() => markEpisode(ep)}
menuProps={epMenu.triggerProps}
menuButtonProps={epMenu.buttonProps}
menuOpen={epMenu.open && epMenu.target === ep}
/>
))}
</NavGrid>
@@ -716,6 +804,7 @@ export function PlexSeasonView({
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
<PlexMenu menu={epMenu} actions={episodes} pin={false} />
</div>
);
}