feat(ui): R3 S2 — loading rows for blank-flash pages, error paths for forever-loading ones

- Blank-flash (showed an empty state on first load before data): DownloadCenter (queue + library
  tabs), AdminUsers (roles + invites), ChatThread, PlaylistsRail now show a loading indicator
  (and an error+retry) instead of a false-empty flash — guarded on `&& !data` so live data isn't
  disturbed on refetch.
- Forever-loading (`isLoading || !data` loops forever on a failed fetch): ConfigPanel, the three
  PlexBrowse subviews (playlist/show/season), and Stats' admin dashboard get an `isError && !data`
  error+retry path before the loading branch.
- Playlists' detail pane already had an error path (isError+retry); its list lives in PlaylistsRail
  (now covered), so no change needed there.
This commit is contained in:
2026-07-22 04:24:38 +02:00
parent a393fd2fb1
commit bf96318dea
7 changed files with 92 additions and 20 deletions
+15 -2
View File
@@ -7,6 +7,7 @@ import { notify } from "../lib/notifications";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip";
import { Section } from "./ui/form";
import { StateMessage } from "./QueryState";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
@@ -159,7 +160,13 @@ function UsersRoles({ me }: { me: Me }) {
return (
<Section card title={t("users.roles.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{rows.length === 0 ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<p className="text-sm text-muted">{t("common.loading")}</p>
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
) : (
<div className="space-y-1.5">
@@ -307,7 +314,13 @@ function AdminInvites() {
return (
<Section card title={t("settings.invites.title")}>
{pending.length === 0 ? (
{invites.isError && !invites.data ? (
<StateMessage tone="error" onRetry={() => invites.refetch()}>
{t("common.loadError")}
</StateMessage>
) : invites.isLoading && !invites.data ? (
<p className="text-sm text-muted">{t("common.loading")}</p>
) : pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
<div className="space-y-1.5">
+10 -1
View File
@@ -5,6 +5,7 @@ import { Send } from "lucide-react";
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { StateMessage } from "./QueryState";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
@@ -139,7 +140,15 @@ export default function ChatThread({
style={fade.style}
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
>
{items.length === 0 ? (
{q.isError && !q.data ? (
<div className="m-auto">
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
</div>
) : q.isPending ? (
<div className="m-auto text-sm text-muted">{t("common.loading")}</div>
) : items.length === 0 ? (
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
) : (
items.map((m) => {
+8
View File
@@ -6,6 +6,7 @@ import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
import { StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
@@ -148,6 +149,13 @@ export default function ConfigPanel() {
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
};
if (q.isError && !data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading || !data) {
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
}
+25 -10
View File
@@ -19,6 +19,7 @@ import {
import clsx from "clsx";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import { StateMessage } from "./QueryState";
import Modal from "./Modal";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
@@ -803,11 +804,18 @@ export default function DownloadCenter() {
</IconBtn>
</DownloadRow>
))}
{queue.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
</div>
)}
{queue.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<div className="text-sm text-muted py-10 text-center">{t("common.loading")}</div>
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
</div>
))}
</div>
</>
)}
@@ -860,11 +868,18 @@ export default function DownloadCenter() {
</IconBtn>
</DownloadRow>
))}
{library.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
)}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<div className="text-sm text-muted py-10 text-center">{t("common.loading")}</div>
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
))}
</div>
</>
)}
+14 -3
View File
@@ -210,9 +210,20 @@ export default function PlaylistsRail({
title: t("playlists.listGroup"),
body: (
<>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
)}
{playlists.length === 0 &&
(listQuery.isError && !listQuery.data ? (
<button
type="button"
onClick={() => listQuery.refetch()}
className="text-xs text-red-400 px-1 py-1 text-left hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</button>
) : listQuery.isLoading ? (
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
) : (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
))}
{playlists.length > 0 && visiblePlaylists.length === 0 && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
+15 -3
View File
@@ -599,7 +599,11 @@ function PlexInfoView({
{t("plex.backToLibrary")}
</button>
</div>
{q.isLoading || !q.data ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !q.data ? (
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
) : (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
@@ -1018,7 +1022,11 @@ function PlexShowView({
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isLoading || !d || !show ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !show ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
@@ -1250,7 +1258,11 @@ function PlexSeasonView({
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d || !se ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !se ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
+5 -1
View File
@@ -247,7 +247,11 @@ function SystemStats() {
{t("stats.introAfter")}
</p>
{q.isLoading ? (
{q.isError && !data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<div className="text-muted py-8">{t("stats.loading")}</div>
) : !data ? (
<div className="text-muted py-8">{t("stats.noData")}</div>