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/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 92a1f94..09bca40 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 { LoadingState, 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 ? ( + + ) : 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 ? ( + + ) : pending.length === 0 ? (

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

) : (
diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 12fb281..d413e6f 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 { LoadingState, StateMessage } from "./QueryState"; import { auditColumns } from "./auditColumns"; // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run @@ -53,8 +54,12 @@ export default function AuditLog() {

{t("audit.intro")}

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

{t("audit.empty")}

) : ( diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 7e9acf1..46c69ad 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 { LoadingState, StateMessage } from "./QueryState"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; import { PageToolbar } from "./PageShell"; @@ -223,8 +224,12 @@ export default function ChannelDiscovery({
- {query.isLoading ? ( -
{t("channels.discovery.loading")}
+ {query.isError && !query.data ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? ( + ) : layout === "table" ? ( {t("channels.loading")}
+ {channelsQuery.isError && !channelsQuery.data ? ( + channelsQuery.refetch()}> + {t("common.loadError")} + + ) : channelsQuery.isLoading ? ( + ) : layout === "table" ? ( - {items.length === 0 ? ( + {q.isError && !q.data ? ( +
+ q.refetch()}> + {t("common.loadError")} + +
+ ) : q.isPending ? ( +
+ +
+ ) : 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..b3c3d36 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 { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { Switch } from "./ui/form"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; @@ -148,8 +149,15 @@ 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")}
; + return ; } const groupKeys = Object.keys(data.groups).sort( diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 408e7b8..92dd99f 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 { 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). @@ -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("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("downloads.empty.done")} +
+ ))}
)} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ad46558..0da24e0 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 { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { useWizard } from "./WizardProvider"; import ViewSwitcher from "./ViewSwitcher"; @@ -492,8 +493,13 @@ export default function Feed({ ); } - if (query.isLoading) return
{t("feed.loading")}
; - if (query.isError) return
{t("feed.loadError")}
; + if (query.isLoading) return ; + if (query.isError && !query.data) + 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..399f2d0 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 { LoadingState, StateMessage } from "./QueryState"; import { relativeTime } from "../lib/format"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { useHistorySubview } from "../lib/history"; @@ -130,8 +131,12 @@ function ConversationList({ )}
{(keyState === "setup" || keyState === "unlock") && } - {q.isLoading && !q.data ? ( -
{t("common.loading")}
+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading && !q.data ? ( + ) : items.length === 0 ? (
{t("messages.empty")}
) : ( @@ -220,8 +225,12 @@ 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 ? ( -
{t("common.loading")}
+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? ( + ) : users.length === 0 ? (
{t("messages.noPeople")} diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index a2d018a..3661ef0 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 { LoadingState, StateMessage } from "./QueryState"; import { useNavigationActions } from "./NavigationProvider"; import { useChannelsActions } from "./ChannelsProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -150,8 +151,12 @@ export default function NotificationsPanel() {
- {q.isLoading && !q.data && clientItems.length === 0 ? ( -
{t("common.loading")}
+ {q.isError && !hasAny ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading && !q.data && clientItems.length === 0 ? ( + ) : !hasAny ? (
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/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 8b46ff1..6678fd2 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 { LoadingState, 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.data ? ( + browseQ.refetch()}> + {t("common.loadError")} + + ) : browseQ.isLoading ? (

{t("plex.loading")}

) : items.length === 0 && episodes.length === 0 ? ( dq && plexFilterCount(filters) > 0 ? ( @@ -594,8 +599,12 @@ function PlexInfoView({ {t("plex.backToLibrary")}
- {q.isLoading || !q.data ? ( -

{t("plex.loading")}

+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !q.data ? ( + ) : ( {t("plex.loading")}

}> - {q.isLoading || !d || !show ? ( -

{t("plex.loading")}

+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !show ? ( + ) : ( <> {/* Hero */} @@ -1245,8 +1258,12 @@ function PlexSeasonView({
- {q.isLoading || !d || !se ? ( -

{t("plex.loading")}

+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !se ? ( + ) : ( <>
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 1b525b5..d9d9448 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 { 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 @@ -576,8 +577,12 @@ export default function PlexPlaylistView({
- {q.isLoading ? ( -

{t("plex.loading")}

+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? ( + ) : 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..374413e --- /dev/null +++ b/frontend/src/components/QueryState.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +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 +// 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". 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/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index bdfdecb..ddd4839 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 { LoadingState, StateMessage } from "./QueryState"; import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; @@ -422,7 +423,13 @@ export default function Scheduler() { }, }); - if (q.isLoading && !data) return
{t("scheduler.loading")}
; + if (q.isError && !data) + return ( + q.refetch()}> + {t("common.loadError")} + + ); + 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 d68d386..7ef0543 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 { LoadingState, StateMessage } from "./QueryState"; import { Section, SettingRow } from "./ui/form"; import { useMe } from "../lib/useMe"; @@ -114,8 +115,12 @@ function Overview({ me }: { me: Me }) { {s.quota_remaining_today.toLocaleString()}
+ ) : status.isError ? ( + status.refetch()}> + {t("common.loadError")} + ) : ( -
{t("stats.sync.loading")}
+ )} @@ -150,6 +155,12 @@ function Overview({ me }: { me: Me }) {
)} + ) : usage.isError ? ( + usage.refetch()}> + {t("common.loadError")} + + ) : usage.isLoading ? ( + ) : (
{t("stats.sync.noUsage")}
)} @@ -242,8 +253,12 @@ function SystemStats() { {t("stats.introAfter")}

- {q.isLoading ? ( -
{t("stats.loading")}
+ {q.isError && !data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? ( + ) : !data ? (
{t("stats.noData")}
) : ( diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 8e9eab4..deed2f7 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -18,5 +18,6 @@ "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." } diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 5f0c71b..a338683 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -18,5 +18,6 @@ "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." } 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 }); }