From d9a08892a5910fffc18d856599943c7b2c2e619b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 03:58:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20R3=20S1=20=E2=80=94=20honest=20erro?= =?UTF-8?q?r=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 12fb281b..106ccff1 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 7e9acf1c..74c58945 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 ad465583..695e9686 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 f4d65dff..44d7b4b2 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 a2d018a3..43d7f1c6 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 8b46ff1d..5c7c272e 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 1b525b57..e02ba77b 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 00000000..4278bb23 --- /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 bdfdecbe..05ae6635 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 d68d3866..054b1584 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 8e9eab48..178b878b 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 5f0c71ba..bb2c83a6 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." }