From bf96318deadeb6a81458b1a006e4f229b594d75f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 04:24:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20R3=20S2=20=E2=80=94=20loading=20row?= =?UTF-8?q?s=20for=20blank-flash=20pages,=20error=20paths=20for=20forever-?= =?UTF-8?q?loading=20ones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- frontend/src/components/AdminUsers.tsx | 17 +++++++++-- frontend/src/components/ChatThread.tsx | 11 ++++++- frontend/src/components/ConfigPanel.tsx | 8 +++++ frontend/src/components/DownloadCenter.tsx | 35 +++++++++++++++------- frontend/src/components/PlaylistsRail.tsx | 17 +++++++++-- frontend/src/components/PlexBrowse.tsx | 18 +++++++++-- frontend/src/components/Stats.tsx | 6 +++- 7 files changed, 92 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 92a1f946..720e1c71 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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 (

{t("users.roles.intro")}

- {rows.length === 0 ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading && !q.data ? ( +

{t("common.loading")}

+ ) : rows.length === 0 ? (

{t("users.roles.empty")}

) : (
@@ -307,7 +314,13 @@ function AdminInvites() { return (
- {pending.length === 0 ? ( + {invites.isError && !invites.data ? ( + invites.refetch()}> + {t("common.loadError")} + + ) : invites.isLoading && !invites.data ? ( +

{t("common.loading")}

+ ) : pending.length === 0 ? (

{t("settings.invites.noPending")}

) : (
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index dd3552eb..07c500e4 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -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 ? ( +
+ q.refetch()}> + {t("common.loadError")} + +
+ ) : q.isPending ? ( +
{t("common.loading")}
+ ) : items.length === 0 ? (
{t("messages.threadEmpty")}
) : ( items.map((m) => { diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 74c5b32e..56f740a8 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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 ( + q.refetch()}> + {t("common.loadError")} + + ); + } if (q.isLoading || !data) { return
{t("config.loading")}
; } diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 408e7b8e..a2f53c79 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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() { ))} - {queue.length === 0 && ( -
- {t("downloads.empty.queue")} -
- )} + {queue.length === 0 && + (jobsQ.isError && !jobsQ.data ? ( + jobsQ.refetch()}> + {t("common.loadError")} + + ) : jobsQ.isLoading ? ( +
{t("common.loading")}
+ ) : ( +
+ {t("downloads.empty.queue")} +
+ ))}
)} @@ -860,11 +868,18 @@ export default function DownloadCenter() { ))} - {library.length === 0 && ( -
- {t("downloads.empty.done")} -
- )} + {library.length === 0 && + (jobsQ.isError && !jobsQ.data ? ( + jobsQ.refetch()}> + {t("common.loadError")} + + ) : jobsQ.isLoading ? ( +
{t("common.loading")}
+ ) : ( +
+ {t("downloads.empty.done")} +
+ ))}
)} diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index d705d2f2..01fbdb74 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -210,9 +210,20 @@ export default function PlaylistsRail({ title: t("playlists.listGroup"), body: ( <> - {playlists.length === 0 && !listQuery.isLoading && ( -
{t("playlists.noneYet")}
- )} + {playlists.length === 0 && + (listQuery.isError && !listQuery.data ? ( + + ) : listQuery.isLoading ? ( +
{t("common.loading")}
+ ) : ( +
{t("playlists.noneYet")}
+ ))} {playlists.length > 0 && visiblePlaylists.length === 0 && (
{t("playlists.noMatches")}
)} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index ecd4b32c..908b8a3d 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -599,7 +599,11 @@ function PlexInfoView({ {t("plex.backToLibrary")} - {q.isLoading || !q.data ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !q.data ? (

{t("plex.loading")}

) : ( {t("plex.loading")}

}> @@ -1018,7 +1022,11 @@ function PlexShowView({
- {q.isLoading || !d || !show ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !show ? (

{t("plex.loading")}

) : ( <> @@ -1250,7 +1258,11 @@ function PlexSeasonView({
- {q.isLoading || !d || !se ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !se ? (

{t("plex.loading")}

) : ( <> diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 054b1584..543453d0 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -247,7 +247,11 @@ function SystemStats() { {t("stats.introAfter")}

- {q.isLoading ? ( + {q.isError && !data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? (
{t("stats.loading")}
) : !data ? (
{t("stats.noData")}