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 { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { Section } from "./ui/form";
|
import { Section } from "./ui/form";
|
||||||
|
import { StateMessage } from "./QueryState";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
@@ -159,7 +160,13 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
return (
|
return (
|
||||||
<Section card title={t("users.roles.title")}>
|
<Section card title={t("users.roles.title")}>
|
||||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
<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>
|
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -307,7 +314,13 @@ function AdminInvites() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section card title={t("settings.invites.title")}>
|
<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>
|
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<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 { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
import { StateMessage } from "./QueryState";
|
||||||
import { relativeTime } from "../lib/format";
|
import { relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
|
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
|
||||||
@@ -139,7 +140,15 @@ export default function ChatThread({
|
|||||||
style={fade.style}
|
style={fade.style}
|
||||||
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
|
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>
|
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((m) => {
|
items.map((m) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
|
import { StateMessage } from "./QueryState";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { Switch } from "./ui/form";
|
import { Switch } from "./ui/form";
|
||||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||||
@@ -148,6 +149,13 @@ export default function ConfigPanel() {
|
|||||||
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
|
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) {
|
if (q.isLoading || !data) {
|
||||||
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
|
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 clsx from "clsx";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
|
import { StateMessage } from "./QueryState";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
// 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).
|
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||||
@@ -803,11 +804,18 @@ export default function DownloadCenter() {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
</DownloadRow>
|
</DownloadRow>
|
||||||
))}
|
))}
|
||||||
{queue.length === 0 && (
|
{queue.length === 0 &&
|
||||||
<div className="text-sm text-muted py-10 text-center">
|
(jobsQ.isError && !jobsQ.data ? (
|
||||||
{t("downloads.empty.queue")}
|
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||||
</div>
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -860,11 +868,18 @@ export default function DownloadCenter() {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
</DownloadRow>
|
</DownloadRow>
|
||||||
))}
|
))}
|
||||||
{library.length === 0 && (
|
{library.length === 0 &&
|
||||||
<div className="text-sm text-muted py-10 text-center">
|
(jobsQ.isError && !jobsQ.data ? (
|
||||||
{t("downloads.empty.done")}
|
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||||
</div>
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -210,9 +210,20 @@ export default function PlaylistsRail({
|
|||||||
title: t("playlists.listGroup"),
|
title: t("playlists.listGroup"),
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
{playlists.length === 0 && !listQuery.isLoading && (
|
{playlists.length === 0 &&
|
||||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
|
(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 && (
|
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
||||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -599,7 +599,11 @@ function PlexInfoView({
|
|||||||
{t("plex.backToLibrary")}
|
{t("plex.backToLibrary")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<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>}>
|
<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">
|
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
||||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
<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>
|
<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">
|
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
||||||
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
<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>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -247,7 +247,11 @@ function SystemStats() {
|
|||||||
{t("stats.introAfter")}
|
{t("stats.introAfter")}
|
||||||
</p>
|
</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>
|
<div className="text-muted py-8">{t("stats.loading")}</div>
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
<div className="text-muted py-8">{t("stats.noData")}</div>
|
<div className="text-muted py-8">{t("stats.noData")}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user