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:
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user