Deep-review (6 dimensions, 3-verifier adversarial pass) surfaced 7 findings, all verified; fixed here. - NavigationProvider + history.ts (medium, correctness): the popstate handlers restored the open channel / Plex sub-view from history.state ONLY, but a fresh ?chan / ?v bookmark lives in the URL with no marker in state — so any overlay's programmatic history.back() (closing a modal/player) silently closed the bookmarked channel or collapsed the sub-view to the grid. Give onPop the same URL fallback the initializers already use (history.ts extracts a shared readView so seed and popstate can't diverge again). - plexCards SeasonCard (medium, a11y): the "In progress" season badge was a reveal-idle swap-badge → display:none on touch, and a season has no progress bar, so an in-progress season was indistinguishable from unwatched on touch. Moved it to the top-left (the toggle owns top-right) and made it always visible. - plexCards watched toggle (low, a11y / WCAG 1.4.1): watched state was color-only on touch (green vs white CheckCircle2). New WatchedIcon distinguishes by SHAPE — CheckCircle2 (watched) vs an empty Circle (not) — used by all three card toggles. - Modal.tsx (low, a11y): the focus trap leaked on Shift+Tab when focus rested on the panel container itself (Node.contains(self) is true, so the pull-back branch never ran). Guard now also checks `active === panel`. - index.css (low, a11y): .reveal-idle faded only on .group:hover though .reveal is revealed on hover AND focus-within → the badge overlapped the control on keyboard focus. Added the focus-within fade. - PlexBrowse.tsx (low, correctness): the ?v=playlist decoder accepted a non-integer id (Number.isFinite); tightened to Number.isInteger(n) && n > 0, matching the ?pl guard. Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): ?v=show and ?chan bookmarks survive a modal open+close (were collapsing/closing before); Modal Shift+Tab from the panel stays inside; in-progress season badge visible on touch; watched toggle shows an empty-circle-vs-check shape difference; 0 console errors.
543 lines
23 KiB
TypeScript
543 lines
23 KiB
TypeScript
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
useInfiniteQuery,
|
|
useQuery,
|
|
useQueryClient,
|
|
type InfiniteData,
|
|
} from "@tanstack/react-query";
|
|
import { User, X } from "lucide-react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { healPlexField } from "../lib/plexSearch";
|
|
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
|
import {
|
|
api,
|
|
EMPTY_PLEX_FILTERS,
|
|
plexFilterCount,
|
|
type PlexCard,
|
|
type PlexFilters,
|
|
type PlexUnifiedResult,
|
|
} from "../lib/api";
|
|
import { plexLang } from "../i18n";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
import { useHistorySubview, type SubviewUrl } from "../lib/history";
|
|
import { toggleWatchState } from "../lib/plexWatch";
|
|
import SortControl from "./SortControl";
|
|
import { PageToolbar } from "./PageShell";
|
|
import ActiveFilterChips from "./ActiveFilterChips";
|
|
import { StateMessage } from "./QueryState";
|
|
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
|
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
|
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
|
|
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
|
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
|
|
|
type Sub =
|
|
| { kind: "grid" }
|
|
| { kind: "show"; id: string }
|
|
| { kind: "season"; showId: string; seasonId: string }
|
|
| { kind: "player"; id: string; queue?: string[] }
|
|
| { kind: "info"; id: string }
|
|
| { kind: "person"; name: string }
|
|
| { kind: "playlist"; id: number };
|
|
|
|
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
|
|
// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person
|
|
// name is the whole tail after the first ":", so a name containing ":" round-trips.
|
|
const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
|
key: "v",
|
|
encode: (v) => {
|
|
switch (v.kind) {
|
|
case "show":
|
|
return `show:${v.id}`;
|
|
case "season":
|
|
return `season:${v.showId}:${v.seasonId}`;
|
|
case "info":
|
|
return `info:${v.id}`;
|
|
case "person":
|
|
return `person:${v.name}`;
|
|
case "playlist":
|
|
return `playlist:${v.id}`;
|
|
default:
|
|
return null; // grid + the ephemeral player are not URL-mirrored
|
|
}
|
|
},
|
|
decode: (raw) => {
|
|
const i = raw.indexOf(":");
|
|
const kind = i === -1 ? raw : raw.slice(0, i);
|
|
const arg = i === -1 ? "" : raw.slice(i + 1);
|
|
switch (kind) {
|
|
case "show":
|
|
return arg ? { kind: "show", id: arg } : null;
|
|
case "info":
|
|
return arg ? { kind: "info", id: arg } : null;
|
|
case "person":
|
|
return arg ? { kind: "person", name: arg } : null;
|
|
case "playlist": {
|
|
// Positive-integer id only — a fractional/zero/negative ?v=playlist:<arg> would open a
|
|
// sub-view for a non-existent id (matches the ?pl guard in PlaylistsProvider.readSelectedId).
|
|
const id = arg ? Number(arg) : NaN;
|
|
return Number.isInteger(id) && id > 0 ? { kind: "playlist", id } : null;
|
|
}
|
|
case "season": {
|
|
const [showId, seasonId] = arg.split(":");
|
|
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
|
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
|
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
|
// (useHistorySubview) so browser Back returns to the grid. Rendered by App for page==="plex" (its own
|
|
// nav module). The cards/strips live in plexCards; the sub-view branches in plexViews.
|
|
|
|
const PAGE = 40;
|
|
|
|
export default function PlexBrowse() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
|
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
|
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
|
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
|
const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
|
const dq = useDebounced(q.trim(), 350);
|
|
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
|
|
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
|
|
const lang = plexLang();
|
|
// The language the grid query is allowed to run at — lags `lang` by one render on a switch so the
|
|
// genre-clear effect (below) can settle first (see the effect for why).
|
|
const [committedLang, setCommittedLang] = useState(lang);
|
|
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
|
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
|
const plexChips = usePlexActiveFilters();
|
|
const clearAllFilters = usePlexClearFilters();
|
|
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
|
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
|
const sortState = healPlexSort(sort, scope);
|
|
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
|
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
|
const fieldState = healPlexField(field, scope);
|
|
const sub = useHistorySubview<Sub>({ kind: "grid" }, PLEX_SUB_URL);
|
|
|
|
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
|
// clear the signal so it doesn't re-open.
|
|
useEffect(() => {
|
|
if (openPlaylist != null) {
|
|
sub.open({ kind: "playlist", id: openPlaylist });
|
|
setPlaylistOpen(null);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openPlaylist]);
|
|
|
|
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
|
// info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open.
|
|
useEffect(() => {
|
|
if (jumpTo != null) {
|
|
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
|
|
sub.open(
|
|
jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id },
|
|
);
|
|
setJumpTo(null);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [jumpTo]);
|
|
|
|
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
|
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
|
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
|
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
|
const scrollRef = useRef(0);
|
|
// Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered
|
|
// grid, and browser Back returns to this same info page — restore where the user had scrolled it
|
|
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
|
|
// different title's info starts at the top.
|
|
const infoScrollRef = useRef(0);
|
|
const scroller = () => document.querySelector("main");
|
|
useLayoutEffect(() => {
|
|
const el = scroller();
|
|
if (!el) return;
|
|
if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
|
|
else if (sub.view.kind === "info" && infoScrollRef.current)
|
|
el.scrollTop = infoScrollRef.current;
|
|
}, [sub.view.kind]);
|
|
|
|
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
|
|
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
|
|
// and never hijack Backspace while typing in a field.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key !== "Backspace") return;
|
|
const el = document.activeElement as HTMLElement | null;
|
|
const tag = (el?.tagName || "").toLowerCase();
|
|
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
|
return;
|
|
if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
|
|
e.preventDefault();
|
|
sub.back();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [sub]);
|
|
|
|
const browseQ = useInfiniteQuery({
|
|
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
|
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
|
|
// settles), not a throwaway one with stale-language genres followed by a second.
|
|
enabled: !!scope && sub.view.kind === "grid" && committedLang === lang,
|
|
initialPageParam: 0,
|
|
queryFn: ({ pageParam }) =>
|
|
api.plexLibrary({
|
|
scope,
|
|
q: dq || undefined,
|
|
field: fieldState,
|
|
sort: sortState,
|
|
show,
|
|
lang,
|
|
filters,
|
|
offset: pageParam as number,
|
|
limit: PAGE,
|
|
}),
|
|
getNextPageParam: (last) => {
|
|
const seen = last.offset + last.items.length;
|
|
return seen < last.total ? seen : undefined;
|
|
},
|
|
});
|
|
|
|
// Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
|
|
// selection made in one language matches nothing against the other's column and would silently empty
|
|
// the grid. On a language flip, drop the genre selection — and gate the grid query on the language
|
|
// being "committed" (below) so we don't fire one throwaway fetch with the stale-language genres and
|
|
// then a second after the clear; the query waits for both to settle into a single fetch.
|
|
useEffect(() => {
|
|
if (committedLang !== lang) {
|
|
setCommittedLang(lang);
|
|
if (filters.genres.length) setFilters({ ...filters, genres: [] });
|
|
}
|
|
}, [lang, committedLang, filters, setFilters]);
|
|
|
|
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
|
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
|
// grid so you SEE who you're exploring, not just chips in the rail.
|
|
const peopleCardsQ = useQuery({
|
|
queryKey: qk.plexPeopleCards(scope, filters.actors),
|
|
queryFn: () => api.plexPeopleCards(filters.actors, scope),
|
|
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
|
|
staleTime: 60_000,
|
|
});
|
|
const personCards = peopleCardsQ.data?.people ?? [];
|
|
const removeActor = (name: string) =>
|
|
setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) });
|
|
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
|
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
|
|
|
// Infinite scroll: auto-load the next page when the sentinel scrolls into view. The sentinel lives
|
|
// in the grid branch, which UNMOUNTS when we drill into an info/show/season page and remounts on
|
|
// Back — as a NEW element. A callback ref (state, not useRef) re-runs this effect on every
|
|
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
|
|
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
|
|
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
|
|
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
|
|
useEffect(() => {
|
|
if (!sentinelEl) return;
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
|
},
|
|
{ rootMargin: "600px" },
|
|
);
|
|
io.observe(sentinelEl);
|
|
return () => io.disconnect();
|
|
}, [sentinelEl, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
function onCard(card: PlexCard) {
|
|
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
|
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
|
else sub.open({ kind: "player", id: card.id });
|
|
}
|
|
function onInfo(card: PlexCard) {
|
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
|
infoScrollRef.current = 0; // fresh info page starts at the top
|
|
sub.open({ kind: "info", id: card.id });
|
|
}
|
|
// Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip).
|
|
// Only when leaving an INFO page do we stash its scroll (so browser Back to that cast row lands where
|
|
// it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a
|
|
// later info page would wrongly restore this stale offset.
|
|
function onOpenPerson(name: string) {
|
|
if (sub.view.kind === "info") infoScrollRef.current = scroller()?.scrollTop ?? 0;
|
|
sub.open({ kind: "person", name });
|
|
}
|
|
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
|
|
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
|
|
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
|
|
function optimisticStatus(id: string, next: string) {
|
|
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: qk.plexLibrary() }, (old) =>
|
|
old
|
|
? {
|
|
...old,
|
|
pages: old.pages.map((pg) => ({
|
|
...pg,
|
|
items: pg.items.map((it) => (it.id === id ? { ...it, status: next } : it)),
|
|
episodes: pg.episodes.map((ep) => (ep.id === id ? { ...ep, status: next } : ep)),
|
|
})),
|
|
}
|
|
: old,
|
|
);
|
|
}
|
|
const toggleWatched = (card: PlexCard) =>
|
|
toggleWatchState(card, t, {
|
|
optimistic: (next) => optimisticStatus(card.id, next),
|
|
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
|
});
|
|
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
|
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
|
|
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
|
function applyFilter(patch: Partial<PlexFilters>) {
|
|
const merged = { ...filters } as unknown as Record<string, unknown>;
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
const cur = merged[k];
|
|
if (Array.isArray(v) && Array.isArray(cur)) {
|
|
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
|
} else {
|
|
merged[k] = v;
|
|
}
|
|
}
|
|
setFilters(merged as unknown as PlexFilters);
|
|
if (patch.actors?.length || patch.directors?.length) setScope("both");
|
|
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page
|
|
sub.open({ kind: "grid" });
|
|
}
|
|
|
|
if (sub.view.kind === "player") {
|
|
// The fallback is portaled for the same reason the player itself is: it is a fixed
|
|
// full-viewport overlay, and this tree renders inside the page scroller.
|
|
return (
|
|
<Suspense
|
|
fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
|
|
>
|
|
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
if (sub.view.kind === "playlist") {
|
|
const pid = sub.view.id;
|
|
return (
|
|
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
|
<PlexPlaylistView
|
|
playlistId={pid}
|
|
onBack={sub.back}
|
|
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
if (sub.view.kind === "info") {
|
|
const infoId = sub.view.id;
|
|
return (
|
|
<PlexInfoView
|
|
id={infoId}
|
|
onBack={sub.back}
|
|
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
|
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
|
onFilter={applyFilter}
|
|
onOpenPerson={onOpenPerson}
|
|
/>
|
|
);
|
|
}
|
|
if (sub.view.kind === "person") {
|
|
const personName = sub.view.name;
|
|
return (
|
|
<PlexPersonView
|
|
name={personName}
|
|
onBack={sub.back}
|
|
onOpenCard={onCard}
|
|
onInfoCard={onInfo}
|
|
onFilter={applyFilter}
|
|
/>
|
|
);
|
|
}
|
|
if (sub.view.kind === "show") {
|
|
const showId = sub.view.id;
|
|
return (
|
|
<PlexShowView
|
|
showId={showId}
|
|
onBack={sub.back}
|
|
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
|
|
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
|
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
|
onFilter={applyFilter}
|
|
onOpenPerson={onOpenPerson}
|
|
/>
|
|
);
|
|
}
|
|
if (sub.view.kind === "season") {
|
|
const { showId, seasonId } = sub.view;
|
|
return (
|
|
<PlexSeasonView
|
|
showId={showId}
|
|
seasonId={seasonId}
|
|
onBack={sub.back}
|
|
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 max-w-[1600px] mx-auto">
|
|
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
|
while only the poster grid scrolls. */}
|
|
<PageToolbar>
|
|
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
|
|
<p className="text-xs text-muted">
|
|
{browseQ.isLoading
|
|
? " "
|
|
: dq
|
|
? t("plex.searchCount", { count: total })
|
|
: t("plex.count", { count: total })}
|
|
</p>
|
|
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
|
|
<SortControl
|
|
options={sortOptions}
|
|
sort={sortState}
|
|
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
|
label={t("plex.filter.sort")}
|
|
ascLabel={t("plex.filter.dir.asc")}
|
|
descLabel={t("plex.filter.dir.desc")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</PageToolbar>
|
|
|
|
<ActiveFilterChips filters={plexChips} onClearAll={clearAllFilters} />
|
|
|
|
{personCards.length > 0 && (
|
|
<div className="mb-4 flex flex-wrap gap-3">
|
|
{personCards.map((p) => (
|
|
<div
|
|
key={p.name}
|
|
className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5"
|
|
>
|
|
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
|
|
<button
|
|
onClick={() => onOpenPerson(p.name)}
|
|
title={t("plex.info.viewPerson", { name: p.name })}
|
|
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg text-left"
|
|
>
|
|
{p.photo ? (
|
|
<img
|
|
src={p.photo}
|
|
alt=""
|
|
loading="lazy"
|
|
className="w-10 h-10 rounded-full object-cover shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
|
<User className="w-5 h-5" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate max-w-[150px] group-hover/person:text-accent">
|
|
{p.name}
|
|
</div>
|
|
<div className="text-2xs text-muted">
|
|
{t("plex.people.count", { count: p.count })}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={() => removeActor(p.name)}
|
|
title={t("plex.people.remove")}
|
|
aria-label={t("plex.people.remove")}
|
|
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{browseQ.isError && !browseQ.data ? (
|
|
<StateMessage tone="error" onRetry={() => browseQ.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : browseQ.isLoading ? (
|
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
|
) : items.length === 0 && episodes.length === 0 ? (
|
|
dq && plexFilterCount(filters) > 0 ? (
|
|
// A search that comes up empty WHILE filters are active is usually the filters, not the
|
|
// query — say so and offer a one-click escape, instead of a bare "No matches".
|
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
|
|
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
|
|
<button
|
|
onClick={() => setFilters(EMPTY_PLEX_FILTERS)}
|
|
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
|
>
|
|
{t("plex.filter.clearAll")}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
|
)
|
|
) : (
|
|
<>
|
|
{/* Titles grid — movies + shows mixed (visually tagged). */}
|
|
{items.length > 0 && (
|
|
<>
|
|
{episodes.length > 0 && (
|
|
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
|
)}
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
|
{items.map((c) => (
|
|
<PlexPosterCard
|
|
key={c.id}
|
|
card={c}
|
|
onOpen={() => onCard(c)}
|
|
onInfo={() => onInfo(c)}
|
|
onToggleWatched={toggleWatched}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
|
|
{episodes.length > 0 && (
|
|
<section className="mt-8">
|
|
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
|
{episodes.map((ep) => (
|
|
<EpisodeCard
|
|
key={ep.id}
|
|
ep={ep}
|
|
withShowTitle
|
|
onPlay={() => onCard(ep)}
|
|
onToggleWatched={() => toggleWatched(ep)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div ref={setSentinelEl} className="h-8" />
|
|
{isFetchingNextPage && (
|
|
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|