fix(plex): the rail says once that its facets failed, with Retry

A failed facets load put the same muted line in up to five sections with nothing to press (the
query never retries by itself), and only the Year section announced it, so a reader who hid that
section heard nothing. The rail now shows one StateMessage with Retry, in the Year section or in
its foot when Year is hidden; the facet sections wait for their data as before.
This commit is contained in:
2026-11-14 20:07:13 +01:00
parent 8d6aa05bc8
commit 7dd50db9b7
2 changed files with 42 additions and 53 deletions
+34 -34
View File
@@ -29,6 +29,7 @@ import { usePlex, usePlexActions } from "./plexContext";
import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input";
import YearPicker from "../../components/ui/YearPicker";
import { StateMessage } from "../../components/ui/QueryState";
import { useRail } from "../../lib/useRail";
import { msg } from "../../lib/notifications";
@@ -85,26 +86,21 @@ export default function PlexSidebar() {
// just lists them as removable chips. Studios now have their own picker section (below).
const peopleActive = filters.directors.length + filters.actors.length > 0;
// A section the facets fill, shown when its facet has values. While the facets are not here, it
// says why it is empty: a first load or a failed one must not read as a library with no genres
// (honest states). The Year section announces it; these only show it, so it is read once.
const facetsState: FacetsState = facets
? "ready"
: facetsQ.isError
? "error"
: facetsQ.isLoading
? "loading"
: "ready";
// A section the facets fill, shown when its facet has values.
const facetSection = (
id: string,
title: string,
has: (f: PlexFacets) => boolean,
body: (f: PlexFacets) => ReactNode,
): PanelItem[] => {
if (facets) return has(facets) ? [{ id, title, body: body(facets) }] : [];
if (facetsState === "ready") return [];
return [{ id, title, body: <FacetsStatus state={facetsState} /> }];
};
): PanelItem[] => (facets && has(facets) ? [{ id, title, body: body(facets) }] : []);
// While the facets are not here, the rail says so ONCE — a first load or a failed one must not
// read as a library with no genres (honest states) — with a way to try again. In the Year section,
// which every library has; in the rail's foot if the reader hid it.
const facetsStatus =
facets || (!facetsQ.isError && !facetsQ.isLoading) ? null : (
<FacetsStatus failed={facetsQ.isError} onRetry={() => void facetsQ.refetch()} />
);
const yearHidden = !!layout.hidden.year;
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
@@ -199,7 +195,12 @@ export default function PlexSidebar() {
id: "year",
title: t("plex.filter.year"),
body: (
<PlexYearFilter filters={filters} facets={facets} facetsState={facetsState} patch={patch} />
<PlexYearFilter
filters={filters}
facets={facets}
status={yearHidden ? null : facetsStatus}
patch={patch}
/>
),
},
...(scope === "movie"
@@ -319,7 +320,9 @@ export default function PlexSidebar() {
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
onTitleClick={openModal}
titleActionLabel={t("views.manageTitle")}
/>
>
{yearHidden && facetsStatus}
</FilterRail>
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />}
</SavedViewsScope>
@@ -496,18 +499,19 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi
export function PlexYearFilter({
filters,
facets,
facetsState = "ready",
status,
patch,
}: {
filters: PlexFilters;
facets: PlexFacets | undefined;
facetsState?: FacetsState;
/** The rail's word on facets still loading or failed, when it shows here. */
status?: ReactNode;
patch: (p: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
{facetsState !== "ready" && <FacetsStatus state={facetsState} announce />}
{status}
{facets && facets.years.length > 0 && (
<YearPicker
years={facets.years}
@@ -534,22 +538,18 @@ export function PlexYearFilter({
);
}
type FacetsState = "ready" | "loading" | "error";
/** Why a facet-filled section is empty while the facets are not here. `announce` makes it a live
* region: one section announces, the rest only show it. */
function FacetsStatus({
state,
announce = false,
}: {
state: Exclude<FacetsState, "ready">;
announce?: boolean;
}) {
/** The facets still loading, or failed with a way to try again — said once for the whole rail. */
export function FacetsStatus({ failed, onRetry }: { failed: boolean; onRetry: () => void }) {
const { t } = useTranslation();
const role = announce ? (state === "error" ? "alert" : "status") : undefined;
if (failed)
return (
<StateMessage tone="error" compact onRetry={onRetry}>
{t("common.loadError")}
</StateMessage>
);
return (
<p className="text-muted px-1 py-1.5 text-xs" role={role}>
{t(state === "error" ? "common.loadError" : "common.loading")}
<p className="text-muted px-1 py-1.5 text-xs" role="status">
{t("common.loading")}
</p>
);
}