feat(ui): R3 S1 — honest error states across the pages

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).
This commit is contained in:
2026-07-22 03:58:51 +02:00
parent 2427de3bbc
commit d9a08892a5
14 changed files with 138 additions and 13 deletions
+6 -1
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 { 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,7 +54,11 @@ 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 ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<div className="text-muted py-8">{t("common.loading")}</div> <div className="text-muted py-8">{t("common.loading")}</div>
) : 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>
+6 -1
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 { 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,7 +224,11 @@ 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 ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
) : query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div> <div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : layout === "table" ? ( ) : layout === "table" ? (
<DataTable <DataTable
+6 -1
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 { 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,7 +710,11 @@ 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 ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div> <div className="text-muted py-8">{t("channels.loading")}</div>
) : layout === "table" ? ( ) : layout === "table" ? (
<DataTable <DataTable
+18 -2
View File
@@ -4,6 +4,7 @@ import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
import Pager, { hasPagerContent } from "./Pager"; import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader"; 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, // 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 // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -44,6 +45,9 @@ export default function DataTable<T>({
pageSizeOptions = [10, 25, 50, 100], pageSizeOptions = [10, 25, 50, 100],
persistKey, persistKey,
emptyText, emptyText,
loading = false,
error = false,
onRetry,
rowClassName, rowClassName,
controlsPosition = "bottom", controlsPosition = "bottom",
controlsLeading, controlsLeading,
@@ -61,6 +65,12 @@ export default function DataTable<T>({
pageSizeOptions?: number[]; pageSizeOptions?: number[];
persistKey?: string; persistKey?: string;
emptyText?: 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; rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both"; controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode; controlsLeading?: ReactNode;
@@ -246,11 +256,17 @@ export default function DataTable<T>({
))} ))}
</div> </div>
{paged.length === 0 && ( {error ? (
<StateMessage tone="error" onRetry={onRetry}>
{t("common.loadError")}
</StateMessage>
) : loading && paged.length === 0 ? (
<StateMessage>{t("common.loading")}</StateMessage>
) : paged.length === 0 ? (
<div className="text-muted py-8 text-center text-sm"> <div className="text-muted py-8 text-center text-sm">
{emptyText ?? t("datatable.empty")} {emptyText ?? t("datatable.empty")}
</div> </div>
)} ) : null}
{controlsPosition === "bottom" && controls} {controlsPosition === "bottom" && controls}
{controlsPosition === "both" && bottomControls} {controlsPosition === "both" && bottomControls}
+7 -1
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 { 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";
@@ -493,7 +494,12 @@ export default function Feed({
} }
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>; if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>; if (query.isError)
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).
+11 -2
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 { 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,7 +131,11 @@ 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 ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : 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,7 +225,11 @@ 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 ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : 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">
@@ -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 { 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,7 +151,11 @@ 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 ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? ( ) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted"> <div className="glass rounded-2xl p-10 text-center text-muted">
+6 -1
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 { 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 ? (
<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 ? (
+6 -1
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 { 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,7 +577,11 @@ export default function PlexPlaylistView({
</button> </button>
</div> </div>
{q.isLoading ? ( {q.isError ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p> <p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p>
) : 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>
+48
View File
@@ -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 (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
role="status"
aria-live="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` 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).
+7
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 { 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,6 +423,12 @@ export default function Scheduler() {
}, },
}); });
if (q.isError && !data)
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
if (q.isLoading && !data) return <div className="p-8 text-muted">{t("scheduler.loading")}</div>; if (q.isLoading && !data) return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
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>;
+5
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 { 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,6 +115,10 @@ 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> <div className="text-muted text-sm">{t("stats.sync.loading")}</div>
)} )}
+3 -1
View File
@@ -18,5 +18,7 @@
"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.",
"empty": "Nothing here yet."
} }
+3 -1
View File
@@ -18,5 +18,7 @@
"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.",
"empty": "Itt még nincs semmi."
} }