From b5573b62d167d0e78b7896f0e878ac259c895475 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 04:35:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20shared=20LoadingState=20=E2=80=94?= =?UTF-8?q?=20a=20centered=20accent=20spinner=20instead=20of=20plain=20loa?= =?UTF-8?q?ding=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the plain "Loading…" text in every R3 loading branch with a single shared LoadingState (a spinning lucide Loader2 in the accent colour + label, centered, role=status/aria-live) — one component so all loads read the same and can be swapped for skeletons later in one place. Applied across Feed, Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages, Scheduler, Stats, PlexBrowse (grid + subviews), PlexPlaylistView, ConfigPanel, DownloadCenter, AdminUsers, ChatThread. Left the small "load more" footer and lazy Suspense fallbacks as-is. --- frontend/src/components/AdminUsers.tsx | 6 +++--- frontend/src/components/AuditLog.tsx | 4 ++-- frontend/src/components/ChannelDiscovery.tsx | 4 ++-- frontend/src/components/Channels.tsx | 4 ++-- frontend/src/components/ChatThread.tsx | 6 ++++-- frontend/src/components/ConfigPanel.tsx | 4 ++-- frontend/src/components/DownloadCenter.tsx | 6 +++--- frontend/src/components/Feed.tsx | 4 ++-- frontend/src/components/Messages.tsx | 6 +++--- .../src/components/NotificationsPanel.tsx | 4 ++-- frontend/src/components/PlexBrowse.tsx | 8 ++++---- frontend/src/components/PlexPlaylistView.tsx | 4 ++-- frontend/src/components/QueryState.tsx | 19 ++++++++++++++++++- frontend/src/components/Scheduler.tsx | 4 ++-- frontend/src/components/Stats.tsx | 6 +++--- 15 files changed, 54 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 720e1c71..09bca40d 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -7,7 +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 { LoadingState, StateMessage } from "./QueryState"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./Tabs"; import { PageToolbar } from "./PageShell"; @@ -165,7 +165,7 @@ function UsersRoles({ me }: { me: Me }) { {t("common.loadError")} ) : q.isLoading && !q.data ? ( -

{t("common.loading")}

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

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

) : ( @@ -319,7 +319,7 @@ function AdminInvites() { {t("common.loadError")} ) : invites.isLoading && !invites.data ? ( -

{t("common.loading")}

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

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

) : ( diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index c4feda96..d413e6fa 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -8,7 +8,7 @@ import { LS } from "../lib/storage"; import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; import DataTable from "./DataTable"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import { auditColumns } from "./auditColumns"; // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run @@ -59,7 +59,7 @@ export default function AuditLog() { {t("common.loadError")} ) : q.isLoading ? ( -
{t("common.loading")}
+ ) : rows.length === 0 ? (

{t("audit.empty")}

) : ( diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 124368bd..46c69ad9 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -15,7 +15,7 @@ import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; import { PageToolbar } from "./PageShell"; @@ -229,7 +229,7 @@ export default function ChannelDiscovery({ {t("common.loadError")} ) : query.isLoading ? ( -
{t("channels.discovery.loading")}
+ ) : layout === "table" ? ( ) : channelsQuery.isLoading ? ( -
{t("channels.loading")}
+ ) : layout === "table" ? ( ) : q.isPending ? ( -
{t("common.loading")}
+
+ +
) : items.length === 0 ? (
{t("messages.threadEmpty")}
) : ( diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 56f740a8..b3c3d36d 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -6,7 +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 { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { Switch } from "./ui/form"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; @@ -157,7 +157,7 @@ export default function ConfigPanel() { ); } if (q.isLoading || !data) { - return
{t("config.loading")}
; + return ; } const groupKeys = Object.keys(data.groups).sort( diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index a2f53c79..92dd99fe 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -19,7 +19,7 @@ import { import clsx from "clsx"; import Tabs, { usePersistedTab } from "./Tabs"; import { PageToolbar } from "./PageShell"; -import { StateMessage } from "./QueryState"; +import { LoadingState, 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). @@ -810,7 +810,7 @@ export default function DownloadCenter() { {t("common.loadError")} ) : jobsQ.isLoading ? ( -
{t("common.loading")}
+ ) : (
{t("downloads.empty.queue")} @@ -874,7 +874,7 @@ export default function DownloadCenter() { {t("common.loadError")} ) : jobsQ.isLoading ? ( -
{t("common.loading")}
+ ) : (
{t("downloads.empty.done")} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 22c02484..0da24e0b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -36,7 +36,7 @@ import { import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { useWizard } from "./WizardProvider"; import ViewSwitcher from "./ViewSwitcher"; @@ -493,7 +493,7 @@ export default function Feed({ ); } - if (query.isLoading) return
{t("feed.loading")}
; + if (query.isLoading) return ; if (query.isError && !query.data) return ( query.refetch()}> diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index b3ab6ff8..399f2d0c 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { api, type Conversation, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import { relativeTime } from "../lib/format"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { useHistorySubview } from "../lib/history"; @@ -136,7 +136,7 @@ function ConversationList({ {t("common.loadError")} ) : q.isLoading && !q.data ? ( -
{t("common.loading")}
+ ) : items.length === 0 ? (
{t("messages.empty")}
) : ( @@ -230,7 +230,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac {t("common.loadError")} ) : q.isLoading ? ( -
{t("common.loading")}
+ ) : users.length === 0 ? (
{t("messages.noPeople")} diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 43d7f1c6..3661ef01 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -35,7 +35,7 @@ import { import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { LEVEL_STYLE } from "./Toaster"; import { PageToolbar } from "./PageShell"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import { useNavigationActions } from "./NavigationProvider"; import { useChannelsActions } from "./ChannelsProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -156,7 +156,7 @@ export default function NotificationsPanel() { {t("common.loadError")} ) : q.isLoading && !q.data && clientItems.length === 0 ? ( -
{t("common.loading")}
+ ) : !hasAny ? (
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 908b8a3d..6678fd27 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -53,7 +53,7 @@ import { } from "../lib/plexDetailUi"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { usePlex, usePlexActions } from "./PlexProvider"; @@ -604,7 +604,7 @@ function PlexInfoView({ {t("common.loadError")} ) : q.isLoading || !q.data ? ( -

{t("plex.loading")}

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

}> ) : q.isLoading || !d || !show ? ( -

{t("plex.loading")}

+ ) : ( <> {/* Hero */} @@ -1263,7 +1263,7 @@ function PlexSeasonView({ {t("common.loadError")} ) : q.isLoading || !d || !se ? ( -

{t("plex.loading")}

+ ) : ( <>
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 0e847bd4..d9d94488 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -33,7 +33,7 @@ import { import { api, type PlexCard } from "../lib/api"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat @@ -582,7 +582,7 @@ export default function PlexPlaylistView({ {t("common.loadError")} ) : q.isLoading ? ( -

{t("plex.loading")}

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

{t("plex.playlist.empty")}

) : ( diff --git a/frontend/src/components/QueryState.tsx b/frontend/src/components/QueryState.tsx index 57f42199..374413e1 100644 --- a/frontend/src/components/QueryState.tsx +++ b/frontend/src/components/QueryState.tsx @@ -1,6 +1,23 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { AlertTriangle } from "lucide-react"; +import { AlertTriangle, Loader2 } from "lucide-react"; + +// 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 ( +
+ + {label ?? t("common.loading")} +
+ ); +} // 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 diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 05ae6635..ddd48393 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -18,7 +18,7 @@ import { } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; @@ -429,7 +429,7 @@ export default function Scheduler() { {t("common.loadError")} ); - if (q.isLoading && !data) return
{t("scheduler.loading")}
; + if (q.isLoading && !data) return ; if (!data) return
{t("common.somethingWrong")}
; const quotaPct = data.quota.daily_budget diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 543453d0..863edc00 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -7,7 +7,7 @@ import { formatEta, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; import { LS, useAccountPersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; -import { StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./QueryState"; import { Section, SettingRow } from "./ui/form"; import { useMe } from "../lib/useMe"; @@ -120,7 +120,7 @@ function Overview({ me }: { me: Me }) { {t("common.loadError")} ) : ( -
{t("stats.sync.loading")}
+ )} @@ -252,7 +252,7 @@ function SystemStats() { {t("common.loadError")} ) : q.isLoading ? ( -
{t("stats.loading")}
+ ) : !data ? (
{t("stats.noData")}
) : (