fix(plex): series-page UX fixes from UAT (7 items)
1. Series detail pages now match the movie info page: the customize menu lives in the hero panel (top-right), the Cast/Seasons/Related strips sit in glassy panels, and the menu is dynamic — Related shows can be toggled (new plexInfoRelated pref). 2. The Plex search box now survives F5 (persisted per-account like the filters). 3. Show + season hero posters get a hover Play/Resume overlay. 4. Card top-right watched toggle can now UN-watch: the watched/in-progress badges were intercepting the click (no pointer-events-none), so the toggle underneath never fired. 5. Episode card: the watched check no longer jumps on hover (badge now shares the toggle's box); every card's title/meta gets its own glassy translucent background and the Add-to-playlist button is always visible (not hover-only). 6. Season-card 'In progress' label is readable again (solid dark badge over the poster). 7. On a season page the show title is clickable → back to the show (history-correct).
This commit is contained in:
parent
46d5572e47
commit
186fdbb0e5
7 changed files with 144 additions and 71 deletions
|
|
@ -268,7 +268,7 @@ export default function App() {
|
|||
// greeted you with a stale query (colliding with a persisted collection filter → confusing
|
||||
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
|
||||
// resets on reload.
|
||||
const [plexQ, setPlexQ] = useState("");
|
||||
const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
|
||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||
// there to see a specific set (the header's "without full history" link).
|
||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ function PlexPosterCard({
|
|||
)}
|
||||
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
||||
{card.status === "watched" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -453,7 +453,7 @@ function PlexPosterCard({
|
|||
)}
|
||||
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||
{card.type === "show" && card.status === "in_progress" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-accent/80 text-accent-fg px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -478,11 +478,12 @@ function PlexPosterCard({
|
|||
</div>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
{card.title}
|
||||
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">{card.title}</div>
|
||||
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -665,12 +666,12 @@ function SeasonCard({
|
|||
</button>
|
||||
)}
|
||||
{watched && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{se.status === "in_progress" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-accent/80 text-accent-fg px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -678,7 +679,11 @@ function SeasonCard({
|
|||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</span>
|
||||
</div>
|
||||
<div onClick={onOpen} className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent">
|
||||
<div
|
||||
onClick={onOpen}
|
||||
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
{se.title}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -688,7 +693,10 @@ function SeasonCard({
|
|||
function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial<PlexFilters>) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{cast.map((c, i) => {
|
||||
|
|
@ -730,7 +738,10 @@ function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (pat
|
|||
function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{related.map((r) => (
|
||||
|
|
@ -812,7 +823,11 @@ function EpisodeCard({
|
|||
</button>
|
||||
)}
|
||||
{ep.status === "watched" && (
|
||||
<span className={`absolute top-1.5 right-1.5 ${onToggleWatched ? "group-hover:opacity-0" : ""}`}>
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
|
||||
onToggleWatched ? "group-hover:opacity-0" : ""
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -822,7 +837,10 @@ function EpisodeCard({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-start gap-1.5">
|
||||
<div
|
||||
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{withShowTitle && ep.show_title && (
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
|
|
@ -843,7 +861,7 @@ function EpisodeCard({
|
|||
onClick={onAdd}
|
||||
title={t("plex.playlist.addEpisode")}
|
||||
aria-label={t("plex.playlist.addEpisode")}
|
||||
className="shrink-0 rounded p-1 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
|
||||
className="shrink-0 rounded p-1 text-muted transition hover:text-accent"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -881,7 +899,7 @@ function PlexShowView({
|
|||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||
const watched = show?.status === "watched";
|
||||
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
|
||||
const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
|
||||
useArtBackdrop(show?.art, artBg);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
|
|
@ -900,16 +918,7 @@ function PlexShowView({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative p-4 max-w-[1200px] mx-auto">
|
||||
<div className="absolute right-4 top-4 z-10">
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={(show?.cast.length ?? 0) > 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 max-w-[1200px] mx-auto">
|
||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||
|
||||
{q.isLoading || !d || !show ? (
|
||||
|
|
@ -917,12 +926,36 @@ function PlexShowView({
|
|||
) : (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<img
|
||||
src={show.thumb}
|
||||
alt=""
|
||||
className="w-32 sm:w-44 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
|
||||
/>
|
||||
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<div className="absolute right-3 top-3 z-10">
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={show.cast.length > 0}
|
||||
showRelated={showRelated}
|
||||
onToggleRelated={toggleRelated}
|
||||
hasRelated={d.related.length > 0}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => show.resume && onPlay(show.resume.id, allKeys)}
|
||||
disabled={!show.resume}
|
||||
className="group/poster relative w-32 sm:w-44 shrink-0 self-start"
|
||||
title={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
>
|
||||
<img
|
||||
src={show.thumb}
|
||||
alt=""
|
||||
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
|
||||
/>
|
||||
{show.resume && (
|
||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
||||
|
|
@ -996,31 +1029,36 @@ function PlexShowView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seasons */}
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 mb-8">
|
||||
{d.seasons.map((se) => (
|
||||
<SeasonCard
|
||||
key={se.id}
|
||||
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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||
{d.seasons.map((se) => (
|
||||
<SeasonCard
|
||||
key={se.id}
|
||||
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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show.cast.length > 0 && showCast && <CastStrip cast={show.cast} onFilter={onFilter} />}
|
||||
{d.related.length > 0 && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
|
||||
{d.related.length > 0 && showRelated && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -1078,30 +1116,44 @@ function PlexSeasonView({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative p-4 max-w-[1200px] mx-auto">
|
||||
<div className="absolute right-4 top-4 z-10">
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 max-w-[1200px] mx-auto">
|
||||
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||
|
||||
{q.isLoading || !d || !se ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<img
|
||||
src={se.thumb}
|
||||
alt=""
|
||||
className="w-28 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
|
||||
/>
|
||||
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<div className="absolute right-3 top-3 z-10">
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={false}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => se.resume && onPlay(se.resume.id, seasonKeys)}
|
||||
disabled={!se.resume}
|
||||
className="group/poster relative w-28 sm:w-40 shrink-0 self-start"
|
||||
title={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
>
|
||||
<img
|
||||
src={se.thumb}
|
||||
alt=""
|
||||
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
|
||||
/>
|
||||
{se.resume && (
|
||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-muted">{d.show.title}</p>
|
||||
<button onClick={onBack} className="text-sm text-muted hover:text-accent transition text-left">
|
||||
{d.show.title}
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||
<p className="text-sm text-muted mt-1">{t("plex.series.episodeCount", { count: se.episode_count })}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@
|
|||
"customize": "Ansicht anpassen",
|
||||
"prefArtBg": "Dezenter Hintergrund",
|
||||
"prefCast": "Besetzung",
|
||||
"prefRelated": "Ähnliche Serien",
|
||||
"stripSource": {
|
||||
"collection": "Sammlungen",
|
||||
"imdb": "IMDb",
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@
|
|||
"customize": "Customize view",
|
||||
"prefArtBg": "Faint backdrop",
|
||||
"prefCast": "Cast & crew",
|
||||
"prefRelated": "Related shows",
|
||||
"stripSource": {
|
||||
"collection": "Collections",
|
||||
"imdb": "IMDb",
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@
|
|||
"customize": "Nézet testreszabása",
|
||||
"prefArtBg": "Halvány háttér",
|
||||
"prefCast": "Szereplők",
|
||||
"prefRelated": "Kapcsolódó sorozatok",
|
||||
"stripSource": {
|
||||
"collection": "Kollekciók",
|
||||
"imdb": "IMDb",
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ export function useDetailPrefs() {
|
|||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
|
||||
plexInfoArtBg?: boolean;
|
||||
plexInfoCast?: boolean;
|
||||
plexInfoRelated?: boolean;
|
||||
};
|
||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
||||
const save = (patch: Record<string, unknown>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
||||
|
|
@ -27,6 +29,7 @@ export function useDetailPrefs() {
|
|||
return {
|
||||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
toggleArtBg: () => {
|
||||
const next = !artBg;
|
||||
setArtBg(next);
|
||||
|
|
@ -37,6 +40,11 @@ export function useDetailPrefs() {
|
|||
setShowCast(next);
|
||||
save({ plexInfoCast: next });
|
||||
},
|
||||
toggleRelated: () => {
|
||||
const next = !showRelated;
|
||||
setShowRelated(next);
|
||||
save({ plexInfoRelated: next });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -75,12 +83,18 @@ export function DetailCustomizeMenu({
|
|||
showCast,
|
||||
onToggleCast,
|
||||
hasCast,
|
||||
showRelated,
|
||||
onToggleRelated,
|
||||
hasRelated,
|
||||
}: {
|
||||
artBg: boolean;
|
||||
onToggleArtBg: () => void;
|
||||
showCast: boolean;
|
||||
onToggleCast: () => void;
|
||||
hasCast: boolean;
|
||||
showRelated?: boolean;
|
||||
onToggleRelated?: () => void;
|
||||
hasRelated?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -105,6 +119,9 @@ export function DetailCustomizeMenu({
|
|||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{hasCast && <PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />}
|
||||
{hasRelated && onToggleRelated && (
|
||||
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const LS = {
|
|||
plexShow: "siftlode.plexShow",
|
||||
plexSort: "siftlode.plexSort",
|
||||
plexFilters: "siftlode.plexFilters",
|
||||
plexQ: "siftlode.plexQ",
|
||||
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
||||
notifHistory: "siftlode.notifications",
|
||||
notifSettings: "siftlode.notifSettings",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue