Merge: promote dev to prod

This commit is contained in:
2026-07-22 04:53:28 +02:00
23 changed files with 283 additions and 55 deletions
+1 -1
View File
@@ -1 +1 @@
0.51.1
0.52.0
+5 -2
View File
@@ -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 (
<div className="min-h-screen grid place-items-center text-muted">
{t("common.somethingWrong")}
<div className="min-h-screen grid place-items-center">
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div>
);
if (!meQuery.data) return <Welcome />;
+15 -2
View File
@@ -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 (
<Section card title={t("users.roles.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{rows.length === 0 ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState />
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
) : (
<div className="space-y-1.5">
@@ -307,7 +314,13 @@ function AdminInvites() {
return (
<Section card title={t("settings.invites.title")}>
{pending.length === 0 ? (
{invites.isError && !invites.data ? (
<StateMessage tone="error" onRetry={() => invites.refetch()}>
{t("common.loadError")}
</StateMessage>
) : invites.isLoading && !invites.data ? (
<LoadingState />
) : pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
<div className="space-y-1.5">
+7 -2
View File
@@ -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() {
<div className="p-4 max-w-5xl w-full mx-auto">
<Section card title={t("audit.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
{q.isLoading ? (
<div className="text-muted py-8">{t("common.loading")}</div>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("audit.empty")}</p>
) : (
+7 -2
View File
@@ -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({
</div>
</PageToolbar>
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
{query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
) : query.isLoading ? (
<LoadingState label={t("channels.discovery.loading")} />
) : layout === "table" ? (
<DataTable
rows={rows}
+7 -2
View File
@@ -28,6 +28,7 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import Overlay from "./Overlay";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
@@ -709,8 +710,12 @@ 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.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={visibleChannels}
+12 -1
View File
@@ -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 { LoadingState, StateMessage } from "./QueryState";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
@@ -139,7 +140,17 @@ 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 ? (
<div className="m-auto">
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
</div>
) : q.isPending ? (
<div className="m-auto">
<LoadingState />
</div>
) : items.length === 0 ? (
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
) : (
items.map((m) => {
+9 -1
View File
@@ -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 (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading || !data) {
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
return <LoadingState label={t("config.loading")} />;
}
const groupKeys = Object.keys(data.groups).sort(
+25 -10
View File
@@ -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() {
</IconBtn>
</DownloadRow>
))}
{queue.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
</div>
)}
{queue.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
</div>
))}
</div>
</>
)}
@@ -860,11 +868,18 @@ export default function DownloadCenter() {
</IconBtn>
</DownloadRow>
))}
{library.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
)}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
))}
</div>
</>
)}
+8 -2
View File
@@ -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 <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.isLoading) return <LoadingState label={t("feed.loading")} />;
if (query.isError && !query.data)
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
// 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).
+13 -4
View File
@@ -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({
)}
</div>
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
{q.isLoading && !q.data ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState />
) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
) : (
@@ -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 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
) : users.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
{t("messages.noPeople")}
@@ -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() {
</PageToolbar>
<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 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
{q.isError && !hasAny ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data && clientItems.length === 0 ? (
<LoadingState />
) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
+7 -3
View File
@@ -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] });
+14 -3
View File
@@ -210,9 +210,20 @@ export default function PlaylistsRail({
title: t("playlists.listGroup"),
body: (
<>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
)}
{playlists.length === 0 &&
(listQuery.isError && !listQuery.data ? (
<button
type="button"
onClick={() => listQuery.refetch()}
className="text-xs text-red-400 px-1 py-1 text-left hover:underline"
>
{t("common.loadError")} {t("common.retry")}
</button>
) : listQuery.isLoading ? (
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
) : (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
))}
{playlists.length > 0 && visiblePlaylists.length === 0 && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
+24 -7
View File
@@ -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() {
</div>
)}
{browseQ.isLoading ? (
{browseQ.isError && !browseQ.data ? (
<StateMessage tone="error" onRetry={() => browseQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : items.length === 0 && episodes.length === 0 ? (
dq && plexFilterCount(filters) > 0 ? (
@@ -594,8 +599,12 @@ function PlexInfoView({
{t("plex.backToLibrary")}
</button>
</div>
{q.isLoading || !q.data ? (
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !q.data ? (
<LoadingState label={t("plex.loading")} />
) : (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexInfo
@@ -1013,8 +1022,12 @@ function PlexShowView({
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isLoading || !d || !show ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !show ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
{/* Hero */}
@@ -1245,8 +1258,12 @@ function PlexSeasonView({
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d || !se ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !se ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
+7 -2
View File
@@ -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({
</button>
</div>
{q.isLoading ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
) : (
+67
View File
@@ -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 (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
role="status"
aria-live="polite"
>
<Loader2 className="w-6 h-6 animate-spin text-accent" aria-hidden />
<span>{label ?? t("common.loading")}</span>
</div>
);
}
// 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}`}
// An error is announced promptly (assertive); a plain loading line is polite.
role={tone === "error" ? "alert" : "status"}
aria-live={tone === "error" ? "assertive" : "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 && <no data>` 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 `&& <no data>` 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.
+8 -1
View File
@@ -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 <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
if (q.isError && !data)
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
if (q.isLoading && !data) return <LoadingState label={t("scheduler.loading")} />;
if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
const quotaPct = data.quota.daily_budget
+18 -3
View File
@@ -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()}
</SettingRow>
</div>
) : status.isError ? (
<StateMessage tone="error" onRetry={() => status.refetch()}>
{t("common.loadError")}
</StateMessage>
) : (
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
<LoadingState label={t("stats.sync.loading")} />
)}
</Section>
@@ -150,6 +155,12 @@ function Overview({ me }: { me: Me }) {
</div>
)}
</>
) : usage.isError ? (
<StateMessage tone="error" onRetry={() => usage.refetch()}>
{t("common.loadError")}
</StateMessage>
) : usage.isLoading ? (
<LoadingState label={t("stats.sync.loading")} />
) : (
<div className="text-muted text-sm">{t("stats.sync.noUsage")}</div>
)}
@@ -242,8 +253,12 @@ function SystemStats() {
{t("stats.introAfter")}
</p>
{q.isLoading ? (
<div className="text-muted py-8">{t("stats.loading")}</div>
{q.isError && !data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("stats.loading")} />
) : !data ? (
<div className="text-muted py-8">{t("stats.noData")}</div>
) : (
+2 -1
View File
@@ -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."
}
+2 -1
View File
@@ -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."
}
+11
View File
@@ -14,6 +14,17 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.52.0",
date: "2026-07-22",
summary:
"Honest loading and error states across the app — pages no longer look empty when a fetch is still loading or has failed.",
fixes: [
"When a page's data is still loading or fails to load (e.g. right after a server restart), it now shows a spinner or a clear error with a Retry button — instead of looking like it's simply empty (\"No channels\", an empty inbox…). Rolled out across the feed, channels, playlists, downloads, messages, notifications, Plex, admin and stats pages.",
'A failed action now shows the server\'s actual reason (e.g. "Not enough quota left today") instead of a generic "failed" message.',
"The startup screen now offers a Retry if the app can't reach the server on load.",
],
},
{
version: "0.51.1",
date: "2026-07-22",
+7 -3
View File
@@ -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 });
}