Files
siftlode/frontend/src/components/PlexDiscover.tsx
T
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00

259 lines
8.5 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 { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
// 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 comfort-pin menu per rail (see ui/ContextMenu), not one per poster.
const pinMenu = useContextMenu<PlexCard>();
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-[150px] shrink-0">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={onToggleWatched}
menuProps={pinMenu.triggerProps}
/>
</div>
))}
</Carousel>
<PlexPinMenu menu={pinMenu} />
</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 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={
<button
type="button"
onClick={() => surpriseQ.refetch()}
aria-label={t("plex.discover.reshuffle")}
title={t("plex.discover.reshuffle")}
className="rounded-full p-1 text-muted transition hover:text-accent"
>
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
</button>
}
{...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>
);
}