`add_pin`'s IntegrityError handler assumed the unique constraint was the only way in and re-raised otherwise, so a target row pruned between `resolve_ref` confirming it and the INSERT (a Plex sync dropping a removed item) tripped the FK and handed the user a 500. Semantically that is the same "no such thing" the pre-check already returns. Untested: the race needs two concurrent transactions. The five per-kind unique constraints had no test at all — the route's own dedupe returns before reaching the DB, and the migration drift check filters constraint diffs out as noise, so they could go missing with every lane green. Frontend, all from the same review: the shelf's unpin row is now `danger` like every other destructive row in the app; the hero's artwork button says what it will actually do and names the item instead of always announcing "Play"; the empty-budget message offers a reroll rather than `StateMessage`'s "Retry", which promised a re-run of something that had not failed; `setState` and the watch-later toggle catch their rejections instead of leaving an unhandled promise and a click that silently does nothing. `StateMessage` gained a `compact` rung, which retires the frontend's only two `!important` overrides — they existed to beat the primitive's own padding from the call site, and were the copyable idiom for the next surface that wanted a compact empty state. Dead vocabulary removed or made load-bearing: `DISCOVER_BUDGETS` was exported, re-exported and consumed by nothing while `Home.tsx` re-typed the same four bands (a `readonly DiscoverBudget[]` accepts any subset, so a fifth band would have typechecked green and rendered no chip); `PIN_KINDS`' value export had no consumer at all; `useComfortPin` no longer publishes `pinIdOf`/`toggle`, which nothing outside the module calls. Three comments corrected against the code they describe: a pointer to a `resolve.py` that was never written, the file header's claim that Home defines no card component of its own, and the `seen` window still quoting the 8 that was measured and abandoned for 32. The rest of the review's survivors are logged on ROADMAP.md under "Deferred — E24 epic-end deep-review (2026-09-08)" — the ones whose dissent was substantive, and the ones whose fix is wider than the finding.
72 lines
3.2 KiB
TypeScript
72 lines
3.2 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
|
import { Button } from "./Button";
|
|
|
|
// 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 = "" }: { label?: ReactNode; className?: string }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div
|
|
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
|
|
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.
|