Files
siftlode/frontend/src/components/PlexDiscover.tsx
T
peter a4737b92e4 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.
2026-09-26 01:51:53 +02:00

265 lines
8.6 KiB
TypeScript

import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
import PosterTile from "./PosterTile";
import { plexLang } from "../i18n";
import { toggleWatchState } from "../lib/plexWatch";
import { usePlex } from "./plexContext";
import { Carousel } from "./ui/Carousel";
import { PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { IconButton } from "./ui/IconButton";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its
// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click /
// info / watched-toggle handlers come from the shell (PlexBrowse) so a rail card behaves exactly like
// a grid card; the watched-toggle optimistically flips THIS surface's cache too.
function CardRail({
title,
ariaLabel,
seeAllLabel,
onSeeAll,
cards,
onOpenCard,
onInfoCard,
onToggleWatched,
action,
}: {
title: ReactNode;
ariaLabel: string;
seeAllLabel?: string;
onSeeAll?: () => void;
cards: PlexCard[];
onOpenCard: (c: PlexCard) => void;
onInfoCard: (c: PlexCard) => void;
onToggleWatched: (c: PlexCard) => void;
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
}) {
// One menu per rail (see ui/ContextMenu), not one per poster.
const menu = useContextMenu<PlexCard>();
const actions = usePlexCardActions(
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched },
menu.open,
);
const heading =
action != null ? (
<span className="inline-flex items-center gap-2">
{title}
{action}
</span>
) : (
title
);
return (
<section className="mb-5">
<Carousel
title={heading}
ariaLabel={ariaLabel}
onSeeAll={onSeeAll}
seeAllLabel={seeAllLabel}
keyboardNav
>
{cards.map((c, i) => (
<div key={c.id} className="w-37.5 shrink-0">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onToggleWatched={onToggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={menu.open && menu.target === c}
/>
</div>
))}
</Carousel>
<PlexMenu menu={menu} actions={actions} />
</section>
);
}
export default function PlexDiscover({
scope,
onOpenCard,
onInfoCard,
onGenre,
onSeeAllRecent,
}: {
scope: string;
onOpenCard: (c: PlexCard) => void;
onInfoCard: (c: PlexCard) => void;
onGenre: (genre: string) => void;
onSeeAllRecent: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const lang = plexLang();
// The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the
// query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same).
const { filters } = usePlex();
const q = useQuery({
queryKey: qk.plexDiscover(scope, lang, filters),
queryFn: () => api.plexDiscover(scope, lang, filters),
});
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
const surpriseQ = useQuery({
queryKey: qk.plexSurprise(scope, lang, filters),
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
staleTime: 0,
});
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
const setSurpriseStatus = (card: PlexCard, status: PlexCard["status"]) =>
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
qk.plexSurprise(scope, lang, filters),
(old) =>
old
? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status } : c)) }
: old,
);
const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) => {
const flip = (cards: PlexCard[]) =>
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang, filters), (old) =>
old
? {
...old,
continue_watching: flip(old.continue_watching),
recently_added: flip(old.recently_added),
because_you_watched: old.because_you_watched
? { ...old.because_you_watched, items: flip(old.because_you_watched.items) }
: null,
}
: old,
);
setSurpriseStatus(card, next);
},
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed
// toggle was left flipped there — put back in place instead.
revert: () => setSurpriseStatus(card, card.status),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
if (q.isError && !q.data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading || !q.data) {
return <LoadingState shape="poster" label={t("plex.loading")} />;
}
const d = q.data;
const surprise = surpriseQ.data?.items ?? [];
const bw = d.because_you_watched;
const nothing =
d.continue_watching.length === 0 &&
d.recently_watched.length === 0 &&
d.recently_added.length === 0 &&
d.genres.length === 0 &&
surprise.length === 0 &&
(!bw || bw.items.length === 0);
if (nothing) {
return <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
}
const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched };
return (
<div>
{d.continue_watching.length > 0 && (
<CardRail
title={t("plex.discover.continueWatching")}
ariaLabel={t("plex.discover.continueWatching")}
cards={d.continue_watching}
{...railProps}
/>
)}
{d.recently_watched.length > 0 && (
<CardRail
title={t("plex.discover.recentlyWatched")}
ariaLabel={t("plex.discover.recentlyWatched")}
cards={d.recently_watched}
{...railProps}
/>
)}
{d.recently_added.length > 0 && (
<CardRail
title={t("plex.discover.recentlyAdded")}
ariaLabel={t("plex.discover.recentlyAdded")}
onSeeAll={onSeeAllRecent}
cards={d.recently_added}
{...railProps}
/>
)}
{surprise.length > 0 && (
<CardRail
title={t("plex.discover.surprise")}
ariaLabel={t("plex.discover.surprise")}
cards={surprise}
action={
<IconButton
onClick={() => surpriseQ.refetch()}
loading={surpriseQ.isFetching}
label={t("plex.discover.reshuffle")}
>
<RotateCcw />
</IconButton>
}
{...railProps}
/>
)}
{d.genres.length > 0 && (
<section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g, i) => (
<PosterTile
key={g.value}
navIndex={i}
title={g.value}
art={g.art}
onClick={() => onGenre(g.value)}
className="h-24"
/>
))}
</NavGrid>
</section>
)}
{bw && bw.items.length > 0 && (
<CardRail
title={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
ariaLabel={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
cards={bw.items}
{...railProps}
/>
)}
</div>
);
}