From d9a08892a5910fffc18d856599943c7b2c2e619b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 03:58:51 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(ui):=20R3=20S1=20=E2=80=94=20honest=20?= =?UTF-8?q?error=20states=20across=20the=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error state didn't exist as a concept: a failed main query rendered as an "empty" list ("No channels" while the request actually died), so a backend restart read as "everything is empty". Add the convention that an empty state may NEVER show while a query is erroring. - New components/QueryState.tsx: shared StateMessage (loading / error+Retry, house style, with role=status/aria-live — the app had zero live regions). - DataTable gains loading/error/onRetry props (suppresses empty-text on error). - isError branch (before the empty branch) added across the false-empty pages: Feed (retry), Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages (conversations + people), Scheduler, Stats, PlexBrowse grid, PlexPlaylistView. ChannelPage's main content is Feed, now covered. common.loadError/empty strings (en+hu). --- frontend/src/components/AuditLog.tsx | 7 ++- frontend/src/components/ChannelDiscovery.tsx | 7 ++- frontend/src/components/Channels.tsx | 7 ++- frontend/src/components/DataTable.tsx | 20 +++++++- frontend/src/components/Feed.tsx | 8 +++- frontend/src/components/Messages.tsx | 13 ++++- .../src/components/NotificationsPanel.tsx | 7 ++- frontend/src/components/PlexBrowse.tsx | 7 ++- frontend/src/components/PlexPlaylistView.tsx | 7 ++- frontend/src/components/QueryState.tsx | 48 +++++++++++++++++++ frontend/src/components/Scheduler.tsx | 7 +++ frontend/src/components/Stats.tsx | 5 ++ frontend/src/i18n/locales/en/common.json | 4 +- frontend/src/i18n/locales/hu/common.json | 4 +- 14 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/QueryState.tsx diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 12fb281..106ccff 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -8,6 +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 { auditColumns } from "./auditColumns"; // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run @@ -53,7 +54,11 @@ export default function AuditLog() {

{t("audit.intro")}

- {q.isLoading ? ( + {q.isError ? ( + q.refetch()}> + {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 7e9acf1..74c5894 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -15,6 +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 ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; import { PageToolbar } from "./PageShell"; @@ -223,7 +224,11 @@ export default function ChannelDiscovery({
- {query.isLoading ? ( + {query.isError ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? (
{t("channels.discovery.loading")}
) : layout === "table" ? ( channelsQuery.refetch()}> + {t("common.loadError")} + + ) : channelsQuery.isLoading ? (
{t("channels.loading")}
) : layout === "table" ? ( ({ pageSizeOptions = [10, 25, 50, 100], persistKey, emptyText, + loading = false, + error = false, + onRetry, rowClassName, controlsPosition = "bottom", controlsLeading, @@ -61,6 +65,12 @@ export default function DataTable({ pageSizeOptions?: number[]; persistKey?: string; emptyText?: string; + // Honest states (R3): when the caller's query is loading or errored, pass these so the table + // shows a loading / error(+retry) message instead of an "empty" list that lies about a failed + // fetch. `error` suppresses the empty-text; `onRetry` wires the retry button. + loading?: boolean; + error?: boolean; + onRetry?: () => void; rowClassName?: (row: T) => string; controlsPosition?: "top" | "bottom" | "both"; controlsLeading?: ReactNode; @@ -246,11 +256,17 @@ export default function DataTable({ ))}
- {paged.length === 0 && ( + {error ? ( + + {t("common.loadError")} + + ) : loading && paged.length === 0 ? ( + {t("common.loading")} + ) : paged.length === 0 ? (
{emptyText ?? t("datatable.empty")}
- )} + ) : null} {controlsPosition === "bottom" && controls} {controlsPosition === "both" && bottomControls} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ad46558..695e968 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -36,6 +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 { PageToolbar } from "./PageShell"; import { useWizard } from "./WizardProvider"; import ViewSwitcher from "./ViewSwitcher"; @@ -493,7 +494,12 @@ export default function Feed({ } if (query.isLoading) return
{t("feed.loading")}
; - if (query.isError) return
{t("feed.loadError")}
; + if (query.isError) + return ( + query.refetch()}> + {t("feed.loadError")} + + ); // 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 // wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless). diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index f4d65df..44d7b4b 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -4,6 +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 { relativeTime } from "../lib/format"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { useHistorySubview } from "../lib/history"; @@ -130,7 +131,11 @@ function ConversationList({ )} {(keyState === "setup" || keyState === "unlock") && } - {q.isLoading && !q.data ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading && !q.data ? (
{t("common.loading")}
) : items.length === 0 ? (
{t("messages.empty")}
@@ -220,7 +225,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac 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" /> - {q.isLoading ? ( + {q.isError ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? (
{t("common.loading")}
) : users.length === 0 ? (
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index a2d018a..43d7f1c 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -35,6 +35,7 @@ import { import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { LEVEL_STYLE } from "./Toaster"; import { PageToolbar } from "./PageShell"; +import { StateMessage } from "./QueryState"; import { useNavigationActions } from "./NavigationProvider"; import { useChannelsActions } from "./ChannelsProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -150,7 +151,11 @@ export default function NotificationsPanel() {
- {q.isLoading && !q.data && clientItems.length === 0 ? ( + {q.isError && !hasAny ? ( + q.refetch()}> + {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 8b46ff1..5c7c272 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -53,6 +53,7 @@ import { } from "../lib/plexDetailUi"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; +import { StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { usePlex, usePlexActions } from "./PlexProvider"; @@ -357,7 +358,11 @@ export default function PlexBrowse() {
)} - {browseQ.isLoading ? ( + {browseQ.isError ? ( + browseQ.refetch()}> + {t("common.loadError")} + + ) : browseQ.isLoading ? (

{t("plex.loading")}

) : 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({
- {q.isLoading ? ( + {q.isError ? ( + q.refetch()}> + {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 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 ( +
+ + {tone === "error" && } + {children} + + {onRetry && ( + + )} +
+ ); +} + +// 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()}
+ ) : status.isError ? ( + status.refetch()}> + {t("common.loadError")} + ) : (
{t("stats.sync.loading")}
)} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 8e9eab4..178b878 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -18,5 +18,7 @@ "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.", "backToTop": "Back to top", - "retry": "Retry" + "retry": "Retry", + "loadError": "Couldn't load this — check your connection and try again.", + "empty": "Nothing here yet." } diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 5f0c71b..bb2c83a 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -18,5 +18,7 @@ "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.", "backToTop": "Vissza a tetejére", - "retry": "Újra" + "retry": "Újra", + "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.", + "empty": "Itt még nincs semmi." } From a393fd2fb14cd811416fd74256eedddd97bd3829 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 04:14:46 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(ui):=20R3=20S1=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20don't=20blank=20data=20on=20transient=20refetch=20e?= =?UTF-8?q?rrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The honest-state branch now guards on `isError && !data`, so a background/window-focus refetch failure keeps already-visible content instead of replacing the list with a full error screen (regression the bare `isError` introduced). Applied to AuditLog, Channels, ChannelDiscovery, PlexBrowse, PlexPlaylistView, Messages(people), Feed — matching the guard already used in Messages(conversations)/NotificationsPanel/Scheduler. - Drop the unused DataTable loading/error/onRetry props (every table page uses the outer StateMessage branch, not the props) — reverts DataTable to its simple empty block. - StateMessage announces errors with role=alert/aria-live=assertive (was polite for all tones). - Remove the now-unused common.empty string (en+hu), left over from the removed QueryState render-prop. --- frontend/src/components/AuditLog.tsx | 2 +- frontend/src/components/ChannelDiscovery.tsx | 2 +- frontend/src/components/Channels.tsx | 2 +- frontend/src/components/DataTable.tsx | 20 ++------------------ frontend/src/components/Feed.tsx | 2 +- frontend/src/components/Messages.tsx | 2 +- frontend/src/components/PlexBrowse.tsx | 2 +- frontend/src/components/PlexPlaylistView.tsx | 2 +- frontend/src/components/QueryState.tsx | 14 ++++++++------ frontend/src/i18n/locales/en/common.json | 3 +-- frontend/src/i18n/locales/hu/common.json | 3 +-- 11 files changed, 19 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 106ccff..c4feda9 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -54,7 +54,7 @@ export default function AuditLog() {

{t("audit.intro")}

- {q.isError ? ( + {q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 74c5894..124368b 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -224,7 +224,7 @@ export default function ChannelDiscovery({
- {query.isError ? ( + {query.isError && !query.data ? ( query.refetch()}> {t("common.loadError")} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 439f8dd..760d6c1 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -710,7 +710,7 @@ export default function Channels() { (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 status chips ride in the fixed band above instead). */} - {channelsQuery.isError ? ( + {channelsQuery.isError && !channelsQuery.data ? ( channelsQuery.refetch()}> {t("common.loadError")} diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 9620dd4..a175007 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -4,7 +4,6 @@ import { sortRows, cycleSort, type SortState } from "../lib/columnSort"; import Pager, { hasPagerContent } from "./Pager"; import { PageToolbar } from "./PageShell"; import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader"; -import { StateMessage } from "./QueryState"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps @@ -45,9 +44,6 @@ export default function DataTable({ pageSizeOptions = [10, 25, 50, 100], persistKey, emptyText, - loading = false, - error = false, - onRetry, rowClassName, controlsPosition = "bottom", controlsLeading, @@ -65,12 +61,6 @@ export default function DataTable({ pageSizeOptions?: number[]; persistKey?: string; emptyText?: string; - // Honest states (R3): when the caller's query is loading or errored, pass these so the table - // shows a loading / error(+retry) message instead of an "empty" list that lies about a failed - // fetch. `error` suppresses the empty-text; `onRetry` wires the retry button. - loading?: boolean; - error?: boolean; - onRetry?: () => void; rowClassName?: (row: T) => string; controlsPosition?: "top" | "bottom" | "both"; controlsLeading?: ReactNode; @@ -256,17 +246,11 @@ export default function DataTable({ ))}
- {error ? ( - - {t("common.loadError")} - - ) : loading && paged.length === 0 ? ( - {t("common.loading")} - ) : paged.length === 0 ? ( + {paged.length === 0 && (
{emptyText ?? t("datatable.empty")}
- ) : null} + )} {controlsPosition === "bottom" && controls} {controlsPosition === "both" && bottomControls} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 695e968..22c0248 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -494,7 +494,7 @@ export default function Feed({ } if (query.isLoading) return
{t("feed.loading")}
; - if (query.isError) + if (query.isError && !query.data) return ( query.refetch()}> {t("feed.loadError")} diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 44d7b4b..b3ab6ff 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -225,7 +225,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac 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" /> - {q.isError ? ( + {q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 5c7c272..ecd4b32 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -358,7 +358,7 @@ export default function PlexBrowse() { )} - {browseQ.isError ? ( + {browseQ.isError && !browseQ.data ? ( browseQ.refetch()}> {t("common.loadError")} diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index e02ba77..0e847bd 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -577,7 +577,7 @@ export default function PlexPlaylistView({ - {q.isError ? ( + {q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} diff --git a/frontend/src/components/QueryState.tsx b/frontend/src/components/QueryState.tsx index 4278bb2..57f4219 100644 --- a/frontend/src/components/QueryState.tsx +++ b/frontend/src/components/QueryState.tsx @@ -22,8 +22,9 @@ export function StateMessage({ return (
{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 (

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

- {rows.length === 0 ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading && !q.data ? ( +

{t("common.loading")}

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

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

) : (
@@ -307,7 +314,13 @@ function AdminInvites() { return (
- {pending.length === 0 ? ( + {invites.isError && !invites.data ? ( + invites.refetch()}> + {t("common.loadError")} + + ) : invites.isLoading && !invites.data ? ( +

{t("common.loading")}

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

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

) : (
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 && ( -
- {t("downloads.empty.queue")} -
- )} + {queue.length === 0 && + (jobsQ.isError && !jobsQ.data ? ( + jobsQ.refetch()}> + {t("common.loadError")} + + ) : jobsQ.isLoading ? ( +
{t("common.loading")}
+ ) : ( +
+ {t("downloads.empty.queue")} +
+ ))}
)} @@ -860,11 +868,18 @@ export default function DownloadCenter() { ))} - {library.length === 0 && ( -
- {t("downloads.empty.done")} -
- )} + {library.length === 0 && + (jobsQ.isError && !jobsQ.data ? ( + jobsQ.refetch()}> + {t("common.loadError")} + + ) : jobsQ.isLoading ? ( +
{t("common.loading")}
+ ) : ( +
+ {t("downloads.empty.done")} +
+ ))}
)} diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index d705d2f..01fbdb7 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -210,9 +210,20 @@ export default function PlaylistsRail({ title: t("playlists.listGroup"), body: ( <> - {playlists.length === 0 && !listQuery.isLoading && ( -
{t("playlists.noneYet")}
- )} + {playlists.length === 0 && + (listQuery.isError && !listQuery.data ? ( + + ) : listQuery.isLoading ? ( +
{t("common.loading")}
+ ) : ( +
{t("playlists.noneYet")}
+ ))} {playlists.length > 0 && visiblePlaylists.length === 0 && (
{t("playlists.noMatches")}
)} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index ecd4b32..908b8a3 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -599,7 +599,11 @@ function PlexInfoView({ {t("plex.backToLibrary")}
- {q.isLoading || !q.data ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !q.data ? (

{t("plex.loading")}

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

}> @@ -1018,7 +1022,11 @@ function PlexShowView({
- {q.isLoading || !d || !show ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !show ? (

{t("plex.loading")}

) : ( <> @@ -1250,7 +1258,11 @@ function PlexSeasonView({
- {q.isLoading || !d || !se ? ( + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !se ? (

{t("plex.loading")}

) : ( <> diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 054b158..543453d 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -247,7 +247,11 @@ function SystemStats() { {t("stats.introAfter")}

- {q.isLoading ? ( + {q.isError && !data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? (
{t("stats.loading")}
) : !data ? (
{t("stats.noData")}
From b5573b62d167d0e78b7896f0e878ac259c895475 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 04:35:02 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(ui):=20shared=20LoadingState=20?= =?UTF-8?q?=E2=80=94=20a=20centered=20accent=20spinner=20instead=20of=20pl?= =?UTF-8?q?ain=20loading=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 720e1c7..09bca40 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 c4feda9..d413e6f 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 124368b..46c69ad 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 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 ? ( -
{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 22c0248..0da24e0 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 b3ab6ff..399f2d0 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 43d7f1c..3661ef0 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 908b8a3..6678fd2 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 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")} ) : ( -
{t("stats.sync.loading")}
+ )} @@ -252,7 +252,7 @@ function SystemStats() { {t("common.loadError")} ) : q.isLoading ? ( -
{t("stats.loading")}
+ ) : !data ? (
{t("stats.noData")}
) : ( 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 ( -
- {t("common.somethingWrong")} +
+ meQuery.refetch()}> + {t("common.somethingWrong")} +
); if (!meQuery.data) return ; diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 1483dc9..2d7ee7f 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -32,7 +32,7 @@ import { Upload, Youtube, } from "lucide-react"; -import { api, type Video } from "../lib/api"; +import { api, HttpError, type Video } from "../lib/api"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { playlistName } from "../lib/playlistName"; @@ -489,10 +489,14 @@ export default function Playlists() { setSelectedId(remaining[0]?.id ?? null); try { 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({ 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] }); diff --git a/frontend/src/lib/youtubeErrors.ts b/frontend/src/lib/youtubeErrors.ts index 5d8440e..22d07af 100644 --- a/frontend/src/lib/youtubeErrors.ts +++ b/frontend/src/lib/youtubeErrors.ts @@ -11,7 +11,9 @@ export function notifyYouTubeActionError( fallbackMessage: string, onConnect?: () => 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({ level: "error", message: i18n.t("channels.notify.needYouTube"), @@ -19,7 +21,9 @@ export function notifyYouTubeActionError( ? { label: i18n.t("channels.notify.connect"), onClick: onConnect } : undefined, }); - } else { - notify({ level: "error", message: fallbackMessage }); + return; } + // 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 }); } From 4871bf4548dfe513662b263aa0288d94c9afd54a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 04:49:40 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(ui):=20R3=20review=20=E2=80=94=20give?= =?UTF-8?q?=20the=20Stats=20API-usage=20block=20its=20own=20loading/error?= =?UTF-8?q?=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'usage' query rendered both its loading and its error state as 'No usage' (the same false-empty R3 targets), while the sibling 'status' query got a proper state. Split the fallback into error(+retry) / loading / empty. --- frontend/src/components/Stats.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 863edc0..7ef0543 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -155,6 +155,12 @@ function Overview({ me }: { me: Me }) {
)} + ) : usage.isError ? ( + usage.refetch()}> + {t("common.loadError")} + + ) : usage.isLoading ? ( + ) : (
{t("stats.sync.noUsage")}
)}