From 186fdbb0e5121fb4385d8b20ad037b81135455ef Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 01:34:30 +0200 Subject: [PATCH] fix(plex): series-page UX fixes from UAT (7 items) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/App.tsx | 2 +- frontend/src/components/PlexBrowse.tsx | 192 ++++++++++++++++--------- frontend/src/i18n/locales/de/plex.json | 1 + frontend/src/i18n/locales/en/plex.json | 1 + frontend/src/i18n/locales/hu/plex.json | 1 + frontend/src/lib/plexDetailUi.tsx | 17 +++ frontend/src/lib/storage.ts | 1 + 7 files changed, 144 insertions(+), 71 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index faf8f66..325008b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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); diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 5ed5f24..5b4e478 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -442,7 +442,7 @@ function PlexPosterCard({ )} {/* Watched badge when not hovering (the toggle replaces it on hover). */} {card.status === "watched" && ( - + {t("plex.watched")} )} @@ -453,7 +453,7 @@ function PlexPosterCard({ )} {/* Aggregate in-progress badge for a partially-watched show. */} {card.type === "show" && card.status === "in_progress" && ( - + {t("plex.inProgress")} )} @@ -478,11 +478,12 @@ function PlexPosterCard({
- {card.title} +
{card.title}
+
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
-
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
); } @@ -665,12 +666,12 @@ function SeasonCard({ )} {watched && ( - + {t("plex.watched")} )} {se.status === "in_progress" && ( - + {t("plex.inProgress")} )} @@ -678,7 +679,11 @@ function SeasonCard({ {t("plex.series.episodeCount", { count: se.episode_count })} -
+
{se.title}
@@ -688,7 +693,10 @@ function SeasonCard({ function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial) => void }) { const { t } = useTranslation(); return ( -
+

{t("plex.info.cast")}

{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 ( -
+

{t("plex.series.related")}

{related.map((r) => ( @@ -812,7 +823,11 @@ function EpisodeCard({ )} {ep.status === "watched" && ( - + )} @@ -822,7 +837,10 @@ function EpisodeCard({
)}
-
+
{withShowTitle && ep.show_title && (
@@ -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" > @@ -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 ( -
-
- 0} - /> -
+
{q.isLoading || !d || !show ? ( @@ -917,12 +926,36 @@ function PlexShowView({ ) : ( <> {/* Hero */} -
- +
+
+ 0} + showRelated={showRelated} + onToggleRelated={toggleRelated} + hasRelated={d.related.length > 0} + /> +
+

{show.title}

@@ -996,31 +1029,36 @@ function PlexShowView({
- {/* Seasons */} -

{t("plex.series.seasons")}

-
- {d.seasons.map((se) => ( - 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 - } - /> - ))} -
+ {/* Seasons — in a glassy panel like the movie info-page strips. */} +
+

{t("plex.series.seasons")}

+
+ {d.seasons.map((se) => ( + 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 + } + /> + ))} +
+
{show.cast.length > 0 && showCast && } - {d.related.length > 0 && } + {d.related.length > 0 && showRelated && } )} @@ -1078,30 +1116,44 @@ function PlexSeasonView({ } return ( -
-
- -
+
{q.isLoading || !d || !se ? (

{t("plex.loading")}

) : ( <> -
- +
+
+ +
+
-

{d.show.title}

+

{se.title}

{t("plex.series.episodeCount", { count: se.episode_count })}

diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 8e58df5..7a45d6b 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -181,6 +181,7 @@ "customize": "Ansicht anpassen", "prefArtBg": "Dezenter Hintergrund", "prefCast": "Besetzung", + "prefRelated": "Ähnliche Serien", "stripSource": { "collection": "Sammlungen", "imdb": "IMDb", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index e168a13..7c895c6 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -181,6 +181,7 @@ "customize": "Customize view", "prefArtBg": "Faint backdrop", "prefCast": "Cast & crew", + "prefRelated": "Related shows", "stripSource": { "collection": "Collections", "imdb": "IMDb", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index c5737f1..64263f0 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index eb75073..eedfbe1 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -15,9 +15,11 @@ export function useDetailPrefs() { const prefs = (qc.getQueryData<{ preferences?: Record }>(["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) => { api.savePrefs(patch).catch(() => {}); qc.setQueryData<{ preferences?: Record } | 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({ > {hasCast && } + {hasRelated && onToggleRelated && ( + + )}
)}
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 4347802..aac8340 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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",