) : items.length === 0 && episodes.length === 0 ? (
dq && plexFilterCount(filters) > 0 ? (
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index 1b525b5..e02ba77 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -33,6 +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";
// 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
@@ -576,7 +577,11 @@ export default function PlexPlaylistView({
diff --git a/frontend/src/components/QueryState.tsx b/frontend/src/components/QueryState.tsx
new file mode 100644
index 0000000..4278bb2
--- /dev/null
+++ b/frontend/src/components/QueryState.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { AlertTriangle } from "lucide-react";
+
+// 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 (
+
+ );
+}
+
+// The honest-state convention every page now follows: check `isError` 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". `StateMessage` is the shared building block;
+// pages wire it into their existing loading/empty ternary (and DataTable takes loading/error props).
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx
index bdfdecb..05ae663 100644
--- a/frontend/src/components/Scheduler.tsx
+++ b/frontend/src/components/Scheduler.tsx
@@ -18,6 +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 { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
@@ -422,6 +423,12 @@ export default function Scheduler() {
},
});
+ if (q.isError && !data)
+ return (
+ q.refetch()}>
+ {t("common.loadError")}
+
+ );
if (q.isLoading && !data) return
{t("scheduler.loading")}
;
if (!data) return
{t("common.somethingWrong")}
;
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx
index d68d386..054b158 100644
--- a/frontend/src/components/Stats.tsx
+++ b/frontend/src/components/Stats.tsx
@@ -7,6 +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 { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
@@ -114,6 +115,10 @@ function Overview({ me }: { me: Me }) {
{s.quota_remaining_today.toLocaleString()}
{tone === "error" && }
@@ -42,7 +43,8 @@ export function StateMessage({
);
}
-// The honest-state convention every page now follows: check `isError` 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". `StateMessage` is the shared building block;
-// pages wire it into their existing loading/empty ternary (and DataTable takes loading/error props).
+// The honest-state convention every page now follows: check `isError && ` 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 `&& ` 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.
diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json
index 178b878..deed2f7 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -19,6 +19,5 @@
"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",
"retry": "Retry",
- "loadError": "Couldn't load this — check your connection and try again.",
- "empty": "Nothing here yet."
+ "loadError": "Couldn't load this — check your connection and try again."
}
diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json
index bb2c83a..a338683 100644
--- a/frontend/src/i18n/locales/hu/common.json
+++ b/frontend/src/i18n/locales/hu/common.json
@@ -19,6 +19,5 @@
"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",
"retry": "Újra",
- "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.",
- "empty": "Itt még nincs semmi."
+ "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra."
}
From bf96318deadeb6a81458b1a006e4f229b594d75f Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 22 Jul 2026 04:24:38 +0200
Subject: [PATCH 3/6] =?UTF-8?q?feat(ui):=20R3=20S2=20=E2=80=94=20loading?=
=?UTF-8?q?=20rows=20for=20blank-flash=20pages,=20error=20paths=20for=20fo?=
=?UTF-8?q?rever-loading=20ones?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
frontend/src/components/AdminUsers.tsx | 17 +++++++++--
frontend/src/components/ChatThread.tsx | 11 ++++++-
frontend/src/components/ConfigPanel.tsx | 8 +++++
frontend/src/components/DownloadCenter.tsx | 35 +++++++++++++++-------
frontend/src/components/PlaylistsRail.tsx | 17 +++++++++--
frontend/src/components/PlexBrowse.tsx | 18 +++++++++--
frontend/src/components/Stats.tsx | 6 +++-
7 files changed, 92 insertions(+), 20 deletions(-)
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index 92a1f94..720e1c7 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -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 (
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx
index dd3552e..07c500e 100644
--- a/frontend/src/components/ChatThread.tsx
+++ b/frontend/src/components/ChatThread.tsx
@@ -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 ? (
+
+ q.refetch()}>
+ {t("common.loadError")}
+
+
+ ) : q.isPending ? (
+
{t("common.loading")}
+ ) : items.length === 0 ? (
{t("messages.threadEmpty")}
) : (
items.map((m) => {
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 74c5b32..56f740a 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -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 (
+ q.refetch()}>
+ {t("common.loadError")}
+
+ );
+ }
if (q.isLoading || !data) {
return
{t("config.loading")}
;
}
diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx
index 408e7b8..a2f53c7 100644
--- a/frontend/src/components/DownloadCenter.tsx
+++ b/frontend/src/components/DownloadCenter.tsx
@@ -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() {
))}
- {queue.length === 0 && (
-
) : (
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 56f740a..b3c3d36 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 a2f53c7..92dd99f 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 ? (
-
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index 0e847bd..d9d9448 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 57f4219..374413e 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 05ae663..ddd4839 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 543453d..863edc0 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")}
) : (
-
) : (
From 1c6b7a9a5aa442036ddcdd62277b01dcae091687 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 22 Jul 2026 04:43:02 +0200
Subject: [PATCH 5/6] =?UTF-8?q?feat(ui):=20R3=20S3=20=E2=80=94=20surface?=
=?UTF-8?q?=20swallowed=20errors?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- notifyYouTubeActionError: on any non-403 error, show the server's own reason (err.detail, e.g.
"Not enough quota left today") instead of the caller's generic fallback, which was discarded
(U-3.2.8). 403 still offers the Connect affordance.
- App boot error now offers a Retry (StateMessage) instead of a dead "Something went wrong" (U-4.2).
- deletePlaylist prefers the server's reason over a blanket "couldn't delete on YouTube" that
misleads when the failure wasn't the YouTube side.
- Already covered in earlier sprints (verified): the live-search "Load more" already surfaces the
quota error inline via err.detail, and ChannelPage subscribe/unsubscribe already route through
notifyYouTubeActionError.
---
frontend/src/App.tsx | 7 +++++--
frontend/src/components/Playlists.tsx | 10 +++++++---
frontend/src/lib/youtubeErrors.ts | 10 +++++++---
3 files changed, 19 insertions(+), 8 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4c4c06d..804c01d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -31,6 +31,7 @@ import PageShell from "./components/PageShell";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
+import { StateMessage } from "./components/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
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.
if (meQuery.error)
return (
-