Files
siftlode/frontend/src/components/ui/QueryState.tsx
T
peter e795d191c5 fix(boot): review round 1 — reconcile the tab pin, one loading clock, honest probe failures
Deep-review round 1 over main..improvement/watch-background-audio.

BootGate
- The tab's account pin is corrected to the account the server answered as.
  The server honours the pin only for an account still signed in to this
  browser and otherwise answers as the default, so a tab pinned to an account
  signed out in another tab ran as the default on the server while reading
  and writing the gone account's local state.
- A mounted tab whose identity changes (its session ended, or the case above)
  reloads instead of rebuilding the tree in place, like every account switch;
  that also drops the previous account's query cache. The account key on the
  tree goes: the readyFor gate already unmounted it on every change.
- An unanswered setup probe shows the error with Retry, like an unanswered
  account probe, instead of the signed-out landing to a signed-in user.
- Comments: /api/me never 401s; the ended-session path is identity → nobody.

Loading states
- LoadingState's reveal delay is one clock across loading states that hand
  over to each other (boot screen → page-chunk fallback → the page's own
  fetching state), instead of a CSS delay restarted by each, which blanked a
  visible spinner for another 300 ms at every hand-off. BootScreen is now the
  shared loading visual, full screen, counting from navigation start.

Plex shared link
- Persists only the dimensions the link carries again. Writing the default
  filters for an omitted `pf` created the stored-filters key, which is what
  tells SavedViewsProvider the account picked filters, so the account's
  default Plex saved view was never applied again.

Watch page
- The Repeat toggle uses the shared pressed-pill paint (a solid fill when on)
  instead of Button variants that differ only in colour, and its flag goes
  through storage.ts readJSON/writeJSON.
- The actions wrap under the title when the row is too narrow, instead of
  squeezing the title into a column a word wide on a phone; the title breaks
  long words.

Comments that still justified code by the removed adopt effects (usePref,
PrefsProvider, FeedViewProvider) are rewritten against the seed-once model.

Tests: BootGate error/retry for both probes, setup mode, a failed background
refetch keeping the app, the pin correction and the reload on an identity
change; the shared reveal clock; the Plex link persist; the audio signature
actually moved off version 4; a video download (av and v) still remuxed to
an mp4. Each mutation-checked RED for its own reason.
2026-09-15 00:53:15 +02:00

111 lines
4.9 KiB
TypeScript

import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "./Button";
// A loading state fades in only after a delay: most loads here answer in tens of milliseconds, and a
// spinner shown for two frames before the content replaces it reads as a flicker, not as feedback. A
// load that really takes a while still gets the spinner, a third of a second in.
//
// The delay is ONE clock across loading states that hand over to each other, not a per-element CSS
// delay. One wait is often several elements in a row — the boot screen, then the page chunk's
// Suspense fallback, then the page's own "fetching" state — and a delay restarted by each would blank
// an already-visible spinner for another 300 ms at every hand-off. A loading state that mounts while
// another is still on screen (or has just left it) continues that one's clock instead.
const LOADING_REVEAL_MS = 300;
const HANDOFF_MS = 100;
const reveal = { live: 0, at: 0, lastGone: -Infinity };
function revealWait(since: number | undefined, delayMs: number): number {
const now = performance.now();
if (reveal.live === 0 && now - reveal.lastGone > HANDOFF_MS) reveal.at = (since ?? now) + delayMs;
return reveal.at - now;
}
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
// visual for a page/section's first load, so all of them read the same (and can be swapped for
// skeletons later in one place). `role=status`/`aria-live` announces it to screen readers.
export function LoadingState({
label,
className = "",
since,
delayMs = LOADING_REVEAL_MS,
}: {
label?: ReactNode;
className?: string;
/** When the wait began (a `performance.now()` time), if earlier than this mount — the boot screen
* passes 0, navigation start. Only used when this state starts a new clock. */
since?: number;
delayMs?: number;
}) {
const { t } = useTranslation();
const [wait] = useState(() => revealWait(since, delayMs));
useEffect(() => {
reveal.live++;
return () => {
reveal.live--;
reveal.lastGone = performance.now();
};
}, []);
return (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
style={wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined}
role="status"
aria-live="polite"
>
<Loader2 className="w-6 h-6 animate-spin text-accent" aria-hidden />
<span>{label ?? t("common.loading")}</span>
</div>
);
}
// Centered inline status line for a page/section's loading and error(+retry) states, in the house
// style (mirrors Playlists' error block). Text-only by design — R3's goal is to stop pages
// rendering an error or a still-loading fetch AS an "empty" state, not to add skeletons. The
// `role=status`/`aria-live` also gives screen readers the state change (the app had zero live
// regions before).
export function StateMessage({
children,
onRetry,
tone = "muted",
compact = false,
className = "",
}: {
children: ReactNode;
onRetry?: () => void;
tone?: "muted" | "error";
/** A rung down in vertical rhythm, for a message that sits INSIDE a section that already has its
* own heading and padding (a rail's empty state) rather than standing alone on a page. Here
* rather than at the call site because the only two consumers so far had to reach for `!py-6` to
* beat this component's own `py-10` — the first `!important` overrides in the frontend, and the
* copyable idiom for the next surface that wants a compact empty state. */
compact?: boolean;
className?: string;
}) {
const { t } = useTranslation();
return (
<div
className={`flex flex-col items-center gap-3 ${compact ? "py-6" : "py-10"} text-sm text-muted ${className}`}
// An error is announced promptly (assertive); a plain loading line is polite.
role={tone === "error" ? "alert" : "status"}
aria-live={tone === "error" ? "assertive" : "polite"}
>
<span className="inline-flex items-center gap-2 text-center">
{tone === "error" && <AlertTriangle className="w-4 h-4 shrink-0 text-danger" />}
{children}
</span>
{onRetry && (
<Button variant="outline" size="xs" type="button" onClick={onRetry}>
{t("common.retry")}
</Button>
)}
</div>
);
}
// The honest-state convention every page now follows: check `isError && <no data>` BEFORE the empty
// branch, so a failed fetch renders this message (with Retry) instead of the page's "empty" text
// — the R3 bug where a backend restart read as "No channels". The `&& <no data>` guard matters: a
// transient refetch error while data is already on screen keeps the data rather than blanking it to
// an error. `StateMessage` is the shared building block pages wire into their loading/empty ternary.