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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user