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 (
0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined} role="status" aria-live="polite" > {label ?? t("common.loading")}
); } // 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 (
{tone === "error" && } {children} {onRetry && ( )}
); } // The honest-state convention every page now follows: check `isError && ` 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 `&& ` 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.