Merge R3: honest states (loading/error across the app)

This commit is contained in:
2026-07-22 04:51:00 +02:00
21 changed files with 271 additions and 54 deletions
+5 -2
View File
@@ -31,6 +31,7 @@ import PageShell from "./components/PageShell";
import GlassTuner from "./components/GlassTuner"; import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock"; import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import { StateMessage } from "./components/QueryState";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner"; import DemoBanner from "./components/DemoBanner";
@@ -294,8 +295,10 @@ export default function App() {
// network/5xx failure, and no data means "not signed in" → the public landing. // network/5xx failure, and no data means "not signed in" → the public landing.
if (meQuery.error) if (meQuery.error)
return ( return (
<div className="min-h-screen grid place-items-center text-muted"> <div className="min-h-screen grid place-items-center">
{t("common.somethingWrong")} <StateMessage tone="error" onRetry={() => meQuery.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div> </div>
); );
if (!meQuery.data) return <Welcome />; if (!meQuery.data) return <Welcome />;
+15 -2
View File
@@ -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 { LoadingState, 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 ? (
<LoadingState />
) : 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 ? (
<LoadingState />
) : 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">
+7 -2
View File
@@ -8,6 +8,7 @@ import { LS } from "../lib/storage";
import { Section } from "./ui/form"; import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import DataTable from "./DataTable"; import DataTable from "./DataTable";
import { LoadingState, StateMessage } from "./QueryState";
import { auditColumns } from "./auditColumns"; import { auditColumns } from "./auditColumns";
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
@@ -53,8 +54,12 @@ export default function AuditLog() {
<div className="p-4 max-w-5xl w-full mx-auto"> <div className="p-4 max-w-5xl w-full mx-auto">
<Section card title={t("audit.title")}> <Section card title={t("audit.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p> <p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
{q.isLoading ? ( {q.isError && !q.data ? (
<div className="text-muted py-8">{t("common.loading")}</div> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("audit.empty")}</p> <p className="text-sm text-muted">{t("audit.empty")}</p>
) : ( ) : (
+7 -2
View File
@@ -15,6 +15,7 @@ import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell"; import AboutCell from "./AboutCell";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid"; import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import ColumnSortControl from "./ColumnSortControl"; import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager"; import Pager from "./Pager";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
@@ -223,8 +224,12 @@ export default function ChannelDiscovery({
</div> </div>
</PageToolbar> </PageToolbar>
<div className="px-4 pb-4 max-w-[96rem] mx-auto"> <div className="px-4 pb-4 max-w-[96rem] mx-auto">
{query.isLoading ? ( {query.isError && !query.data ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div> <StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
) : query.isLoading ? (
<LoadingState label={t("channels.discovery.loading")} />
) : layout === "table" ? ( ) : layout === "table" ? (
<DataTable <DataTable
rows={rows} rows={rows}
+7 -2
View File
@@ -28,6 +28,7 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid"; import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import Overlay from "./Overlay"; import Overlay from "./Overlay";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl"; import ColumnSortControl from "./ColumnSortControl";
@@ -709,8 +710,12 @@ export default function Channels() {
(status chips + pager) rides in the fixed band right above it (controlsInBand). The (status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and card layout swaps the table for a virtualized grid over the same rows (its pager and
status chips ride in the fixed band above instead). */} status chips ride in the fixed band above instead). */}
{channelsQuery.isLoading ? ( {channelsQuery.isError && !channelsQuery.data ? (
<div className="text-muted py-8">{t("channels.loading")}</div> <StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState label={t("channels.loading")} />
) : layout === "table" ? ( ) : layout === "table" ? (
<DataTable <DataTable
rows={visibleChannels} rows={visibleChannels}
+12 -1
View File
@@ -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 { LoadingState, 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,17 @@ 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">
<LoadingState />
</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) => {
+9 -1
View File
@@ -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 { LoadingState, 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,8 +149,15 @@ 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 <LoadingState label={t("config.loading")} />;
} }
const groupKeys = Object.keys(data.groups).sort( const groupKeys = Object.keys(data.groups).sort(
+25 -10
View File
@@ -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 { LoadingState, 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 ? (
<LoadingState />
) : (
<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 ? (
<LoadingState />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
))}
</div> </div>
</> </>
)} )}
+8 -2
View File
@@ -36,6 +36,7 @@ import {
import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips"; import ActiveFilterChips from "./ActiveFilterChips";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider"; import { useWizard } from "./WizardProvider";
import ViewSwitcher from "./ViewSwitcher"; import ViewSwitcher from "./ViewSwitcher";
@@ -492,8 +493,13 @@ 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) return <div className="p-8 text-muted">{t("feed.loadError")}</div>; if (query.isError && !query.data)
return (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("feed.loadError")}
</StateMessage>
);
// Empty "my" feed with no YouTube access. The shared demo account can never connect // Empty "my" feed with no YouTube access. The shared demo account can never connect
// YouTube, so it gets a gentle nudge into the shared library instead of the connect // YouTube, so it gets a gentle nudge into the shared library instead of the connect
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless). // wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
+13 -4
View File
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api"; import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { LoadingState, StateMessage } from "./QueryState";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history"; import { useHistorySubview } from "../lib/history";
@@ -130,8 +131,12 @@ function ConversationList({
)} )}
</div> </div>
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />} {(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
{q.isLoading && !q.data ? ( {q.isError && !q.data ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div> <div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
) : ( ) : (
@@ -220,8 +225,12 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
placeholder={t("messages.searchPeople")} placeholder={t("messages.searchPeople")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/> />
{q.isLoading ? ( {q.isError && !q.data ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
) : users.length === 0 ? ( ) : users.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted"> <div className="glass rounded-2xl p-10 text-center text-muted">
{t("messages.noPeople")} {t("messages.noPeople")}
@@ -35,6 +35,7 @@ import {
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { LEVEL_STYLE } from "./Toaster"; import { LEVEL_STYLE } from "./Toaster";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { useChannelsActions } from "./ChannelsProvider"; import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
@@ -150,8 +151,12 @@ export default function NotificationsPanel() {
</PageToolbar> </PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4"> <div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isLoading && !q.data && clientItems.length === 0 ? ( {q.isError && !hasAny ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data && clientItems.length === 0 ? (
<LoadingState />
) : !hasAny ? ( ) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted"> <div className="glass rounded-2xl p-10 text-center text-muted">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" /> <Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
+7 -3
View File
@@ -32,7 +32,7 @@ import {
Upload, Upload,
Youtube, Youtube,
} from "lucide-react"; } from "lucide-react";
import { api, type Video } from "../lib/api"; import { api, HttpError, type Video } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName"; import { playlistName } from "../lib/playlistName";
@@ -489,10 +489,14 @@ export default function Playlists() {
setSelectedId(remaining[0]?.id ?? null); setSelectedId(remaining[0]?.id ?? null);
try { try {
await api.deletePlaylist(deletedId, onYoutube); await api.deletePlaylist(deletedId, onYoutube);
} catch { } catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({ notify({
level: "warning", level: "warning",
message: onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"), message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
}); });
} }
qc.removeQueries({ queryKey: ["playlist", deletedId] }); qc.removeQueries({ queryKey: ["playlist", deletedId] });
+14 -3
View File
@@ -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>
)} )}
+24 -7
View File
@@ -53,6 +53,7 @@ import {
} from "../lib/plexDetailUi"; } from "../lib/plexDetailUi";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { usePlex, usePlexActions } from "./PlexProvider"; import { usePlex, usePlexActions } from "./PlexProvider";
@@ -357,7 +358,11 @@ export default function PlexBrowse() {
</div> </div>
)} )}
{browseQ.isLoading ? ( {browseQ.isError && !browseQ.data ? (
<StateMessage tone="error" onRetry={() => browseQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p> <p className="text-muted text-sm">{t("plex.loading")}</p>
) : items.length === 0 && episodes.length === 0 ? ( ) : items.length === 0 && episodes.length === 0 ? (
dq && plexFilterCount(filters) > 0 ? ( dq && plexFilterCount(filters) > 0 ? (
@@ -594,8 +599,12 @@ function PlexInfoView({
{t("plex.backToLibrary")} {t("plex.backToLibrary")}
</button> </button>
</div> </div>
{q.isLoading || !q.data ? ( {q.isError && !q.data ? (
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !q.data ? (
<LoadingState label={t("plex.loading")} />
) : ( ) : (
<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>}>
<PlexInfo <PlexInfo
@@ -1013,8 +1022,12 @@ 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 ? (
<p className="text-muted text-sm">{t("plex.loading")}</p> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !show ? (
<LoadingState label={t("plex.loading")} />
) : ( ) : (
<> <>
{/* Hero */} {/* Hero */}
@@ -1245,8 +1258,12 @@ 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 ? (
<p className="text-muted text-sm">{t("plex.loading")}</p> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !se ? (
<LoadingState label={t("plex.loading")} />
) : ( ) : (
<> <>
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5"> <div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
+7 -2
View File
@@ -33,6 +33,7 @@ import {
import { api, type PlexCard } from "../lib/api"; import { api, type PlexCard } from "../lib/api";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // 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 // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -576,8 +577,12 @@ export default function PlexPlaylistView({
</button> </button>
</div> </div>
{q.isLoading ? ( {q.isError && !q.data ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p> <p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
) : ( ) : (
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
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
// rendering an error or a still-loading fetch AS an "empty" state, not to add skeletons. The
// `role=status`/`aria-live` also gives screen readers the state change (the app had zero live
// regions before).
export function StateMessage({
children,
onRetry,
tone = "muted",
className = "",
}: {
children: ReactNode;
onRetry?: () => void;
tone?: "muted" | "error";
className?: string;
}) {
const { t } = useTranslation();
return (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
// An error is announced promptly (assertive); a plain loading line is polite.
role={tone === "error" ? "alert" : "status"}
aria-live={tone === "error" ? "assertive" : "polite"}
>
<span className="inline-flex items-center gap-2 text-center">
{tone === "error" && <AlertTriangle className="w-4 h-4 shrink-0 text-red-400" />}
{children}
</span>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
>
{t("common.retry")}
</button>
)}
</div>
);
}
// The honest-state convention every page now follows: check `isError && <no data>` BEFORE the empty
// branch, so a failed fetch renders this message (with Retry) instead of the page's "empty" text
// — the R3 bug where a backend restart read as "No channels". The `&& <no data>` guard matters: a
// transient refetch error while data is already on screen keeps the data rather than blanking it to
// an error. `StateMessage` is the shared building block pages wire into their loading/empty ternary.
+8 -1
View File
@@ -18,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { LoadingState, StateMessage } from "./QueryState";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@@ -422,7 +423,13 @@ export default function Scheduler() {
}, },
}); });
if (q.isLoading && !data) return <div className="p-8 text-muted">{t("scheduler.loading")}</div>; if (q.isError && !data)
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
if (q.isLoading && !data) return <LoadingState label={t("scheduler.loading")} />;
if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>; if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
const quotaPct = data.quota.daily_budget const quotaPct = data.quota.daily_budget
+18 -3
View File
@@ -7,6 +7,7 @@ import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, useAccountPersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { LoadingState, StateMessage } from "./QueryState";
import { Section, SettingRow } from "./ui/form"; import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
@@ -114,8 +115,12 @@ function Overview({ me }: { me: Me }) {
{s.quota_remaining_today.toLocaleString()} {s.quota_remaining_today.toLocaleString()}
</SettingRow> </SettingRow>
</div> </div>
) : status.isError ? (
<StateMessage tone="error" onRetry={() => status.refetch()}>
{t("common.loadError")}
</StateMessage>
) : ( ) : (
<div className="text-muted text-sm">{t("stats.sync.loading")}</div> <LoadingState label={t("stats.sync.loading")} />
)} )}
</Section> </Section>
@@ -150,6 +155,12 @@ function Overview({ me }: { me: Me }) {
</div> </div>
)} )}
</> </>
) : usage.isError ? (
<StateMessage tone="error" onRetry={() => usage.refetch()}>
{t("common.loadError")}
</StateMessage>
) : usage.isLoading ? (
<LoadingState label={t("stats.sync.loading")} />
) : ( ) : (
<div className="text-muted text-sm">{t("stats.sync.noUsage")}</div> <div className="text-muted text-sm">{t("stats.sync.noUsage")}</div>
)} )}
@@ -242,8 +253,12 @@ function SystemStats() {
{t("stats.introAfter")} {t("stats.introAfter")}
</p> </p>
{q.isLoading ? ( {q.isError && !data ? (
<div className="text-muted py-8">{t("stats.loading")}</div> <StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("stats.loading")} />
) : !data ? ( ) : !data ? (
<div className="text-muted py-8">{t("stats.noData")}</div> <div className="text-muted py-8">{t("stats.noData")}</div>
) : ( ) : (
+2 -1
View File
@@ -18,5 +18,6 @@
"demoTitle": "Demo account", "demoTitle": "Demo account",
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.", "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top", "backToTop": "Back to top",
"retry": "Retry" "retry": "Retry",
"loadError": "Couldn't load this — check your connection and try again."
} }
+2 -1
View File
@@ -18,5 +18,6 @@
"demoTitle": "Demo fiók", "demoTitle": "Demo fiók",
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.", "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére", "backToTop": "Vissza a tetejére",
"retry": "Újra" "retry": "Újra",
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra."
} }
+7 -3
View File
@@ -11,7 +11,9 @@ export function notifyYouTubeActionError(
fallbackMessage: string, fallbackMessage: string,
onConnect?: () => void, onConnect?: () => void,
): void { ): void {
if (err instanceof HttpError && err.status === 403) { const httpErr = err instanceof HttpError ? err : null;
if (httpErr?.status === 403) {
// A 403 on a YouTube write action means the write scope isn't granted — offer to connect.
notify({ notify({
level: "error", level: "error",
message: i18n.t("channels.notify.needYouTube"), message: i18n.t("channels.notify.needYouTube"),
@@ -19,7 +21,9 @@ export function notifyYouTubeActionError(
? { label: i18n.t("channels.notify.connect"), onClick: onConnect } ? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined, : undefined,
}); });
} else { return;
notify({ level: "error", message: fallbackMessage });
} }
// Any other error: prefer the server's own reason (e.g. "Not enough quota left today") over the
// caller's generic fallback — the useful detail was being discarded (U-3.2.8).
notify({ level: "error", message: httpErr?.detail || fallbackMessage });
} }