feat(ui): shared LoadingState — a centered accent spinner instead of plain loading text
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.
This commit is contained in:
@@ -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")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data ? (
|
||||
<p className="text-sm text-muted">{t("common.loading")}</p>
|
||||
<LoadingState />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
||||
) : (
|
||||
@@ -319,7 +319,7 @@ function AdminInvites() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : invites.isLoading && !invites.data ? (
|
||||
<p className="text-sm text-muted">{t("common.loading")}</p>
|
||||
<LoadingState />
|
||||
) : pending.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||
) : (
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<div className="text-muted py-8">{t("common.loading")}</div>
|
||||
<LoadingState />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : query.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
||||
<LoadingState label={t("channels.discovery.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Overlay from "./Overlay";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
@@ -715,7 +715,7 @@ export default function Channels() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : channelsQuery.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||
<LoadingState label={t("channels.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={visibleChannels}
|
||||
|
||||
@@ -5,7 +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 { LoadingState, StateMessage } from "./QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
|
||||
@@ -147,7 +147,9 @@ export default function ChatThread({
|
||||
</StateMessage>
|
||||
</div>
|
||||
) : q.isPending ? (
|
||||
<div className="m-auto text-sm text-muted">{t("common.loading")}</div>
|
||||
<div className="m-auto">
|
||||
<LoadingState />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
|
||||
) : (
|
||||
|
||||
@@ -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 <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
|
||||
return <LoadingState label={t("config.loading")} />;
|
||||
}
|
||||
|
||||
const groupKeys = Object.keys(data.groups).sort(
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : jobsQ.isLoading ? (
|
||||
<div className="text-sm text-muted py-10 text-center">{t("common.loading")}</div>
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.queue")}
|
||||
@@ -874,7 +874,7 @@ export default function DownloadCenter() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : jobsQ.isLoading ? (
|
||||
<div className="text-sm text-muted py-10 text-center">{t("common.loading")}</div>
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.done")}
|
||||
|
||||
@@ -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 <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||
if (query.isLoading) return <LoadingState label={t("feed.loading")} />;
|
||||
if (query.isError && !query.data)
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
<LoadingState />
|
||||
) : items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||
) : (
|
||||
@@ -230,7 +230,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
<LoadingState />
|
||||
) : users.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">
|
||||
{t("messages.noPeople")}
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data && clientItems.length === 0 ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
<LoadingState />
|
||||
) : !hasAny ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">
|
||||
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !q.data ? (
|
||||
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexInfo
|
||||
@@ -1027,7 +1027,7 @@ function PlexShowView({
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !show ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
{/* Hero */}
|
||||
@@ -1263,7 +1263,7 @@ function PlexSeasonView({
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !se ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent" aria-hidden />
|
||||
<span>{label ?? t("common.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
);
|
||||
if (q.isLoading && !data) return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
||||
if (q.isLoading && !data) return <LoadingState label={t("scheduler.loading")} />;
|
||||
if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
|
||||
|
||||
const quotaPct = data.quota.daily_budget
|
||||
|
||||
@@ -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")}
|
||||
</StateMessage>
|
||||
) : (
|
||||
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
|
||||
<LoadingState label={t("stats.sync.loading")} />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -252,7 +252,7 @@ function SystemStats() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<div className="text-muted py-8">{t("stats.loading")}</div>
|
||||
<LoadingState label={t("stats.loading")} />
|
||||
) : !data ? (
|
||||
<div className="text-muted py-8">{t("stats.noData")}</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user