From 44feb2f08a58d206db60e54eddd7f692f6d8364e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 14:33:25 -0400 Subject: [PATCH] refactor(admin): show per-section loading and error states - Replace page-level loading gate with skeletons per section on dashboard and stats - Surface query errors inline instead of blocking the whole page - Disable "Scan All Libraries" when no libraries are configured --- web/src/pages/AdminDashboard.tsx | 338 +++++++++++++++++++++---------- web/src/pages/AdminStats.tsx | 173 ++++++++++------ 2 files changed, 344 insertions(+), 167 deletions(-) diff --git a/web/src/pages/AdminDashboard.tsx b/web/src/pages/AdminDashboard.tsx index d10db99d..1b21c88a 100644 --- a/web/src/pages/AdminDashboard.tsx +++ b/web/src/pages/AdminDashboard.tsx @@ -40,34 +40,15 @@ import type { } from "@/api/types"; export default function AdminDashboard() { - const { data: stats, isLoading: statsLoading, refetch: refetchStats } = useAdminStats(); - const { - data: sessions = [], - isLoading: sessionsLoading, - refetch: refreshSessions, - } = useAdminSessions(); - const { data: libraries = [] } = useAdminLibraries(); - const { data: users = [] } = useAdminUsers(); + const statsQuery = useAdminStats(); + const sessionsQuery = useAdminSessions(); + const librariesQuery = useAdminLibraries(); + const usersQuery = useAdminUsers(); const scanAll = useScanAllLibraries(); - const loading = statsLoading || sessionsLoading; - - if (loading) - return ( -
-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- -
- ); + const sessions = sessionsQuery.data ?? []; + const libraries = librariesQuery.data ?? []; + const users = usersQuery.data ?? []; return (
@@ -84,8 +65,8 @@ export default function AdminDashboard() { variant="outline" size="sm" onClick={() => { - refetchStats(); - void refreshSessions(); + void statsQuery.refetch(); + void sessionsQuery.refetch(); }} > @@ -99,7 +80,7 @@ export default function AdminDashboard() { scanAll.mutate(); } }} - disabled={scanAll.isPending} + disabled={scanAll.isPending || libraries.length === 0} > Scan All Libraries @@ -107,56 +88,67 @@ export default function AdminDashboard() {
- {/* Stats row */} - {stats && } + - {stats?.watch_provider_activity && ( - + {statsQuery.data?.watch_provider_activity && ( + )} - {/* Now Playing */} - {sessions.length > 0 && ( -
-
-
Now Playing
- - View all {sessions.length} streams › - -
-
- {sessions.slice(0, 4).map((session) => ( - - ))} -
- {sessions.length > 4 && ( - - +{sessions.length - 4} more active streams - - )} -
- )} + - {/* Two-column: Libraries + Users */}
- - + +
- {/* Recent Activity */} - + ); } // --- Sub-components --- -function StatsRow({ stats, sessionCount }: { stats: AdminStats; sessionCount: number }) { +function StatsRow({ + stats, + sessionCount, + isLoading, + error, +}: { + stats: AdminStats | undefined; + sessionCount: number; + isLoading: boolean; + error: unknown; +}) { + if (isLoading || !stats) { + if (error) { + return ; + } + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + const storageGB = stats.total_storage_bytes / (1024 * 1024 * 1024); const storageTB = storageGB / 1024; const storageDisplay = @@ -411,7 +403,71 @@ function StreamCard({ session }: { session: AdminSession }) { ); } -function LibrariesCard({ libraries }: { libraries: LibraryType[] }) { +function NowPlayingSection({ + sessions, + isLoading, + error, +}: { + sessions: AdminSession[]; + isLoading: boolean; + error: unknown; +}) { + if (error) return null; + + if (isLoading) { + return ( +
+
+
Now Playing
+
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (sessions.length === 0) return null; + + return ( +
+
+
Now Playing
+ + View all {sessions.length} streams › + +
+
+ {sessions.slice(0, 4).map((session) => ( + + ))} +
+ {sessions.length > 4 && ( + + +{sessions.length - 4} more active streams + + )} +
+ ); +} + +function LibrariesCard({ + libraries, + isLoading, + error, +}: { + libraries: LibraryType[]; + isLoading: boolean; + error: unknown; +}) { const scanLibrary = useScanLibrary(); return ( @@ -426,7 +482,11 @@ function LibrariesCard({ libraries }: { libraries: LibraryType[] }) { - {libraries.length === 0 ? ( + {isLoading ? ( + + ) : error ? ( + + ) : libraries.length === 0 ? (
No libraries configured.
@@ -478,7 +538,15 @@ function LibrariesCard({ libraries }: { libraries: LibraryType[] }) { ); } -function UsersCard({ users }: { users: AdminUser[] }) { +function UsersCard({ + users, + isLoading, + error, +}: { + users: AdminUser[]; + isLoading: boolean; + error: unknown; +}) { const navigate = useNavigate(); return ( @@ -493,7 +561,11 @@ function UsersCard({ users }: { users: AdminUser[] }) { - {users.length === 0 ? ( + {isLoading ? ( + + ) : error ? ( + + ) : users.length === 0 ? (
No users.
) : ( @@ -543,8 +615,16 @@ function UsersCard({ users }: { users: AdminUser[] }) { ); } -function ActivityCard({ sessions }: { sessions: AdminSession[] }) { - if (sessions.length === 0) return null; +function ActivityCard({ + sessions, + isLoading, + error, +}: { + sessions: AdminSession[]; + isLoading: boolean; + error: unknown; +}) { + if (!isLoading && !error && sessions.length === 0) return null; return ( @@ -558,43 +638,49 @@ function ActivityCard({ sessions }: { sessions: AdminSession[] }) { -
- {sessions.slice(0, 10).map((s) => { - const isEp = s.series_name && s.season_number != null && s.episode_number != null; - const title = isEp - ? s.episode_name || `S${s.season_number}E${s.episode_number}` - : s.media_title || `File #${s.media_file_id}`; - const username = s.username || `User #${s.user_id}`; - return ( -
-
- -
-
-
- {username} - {" started watching "} - - {title} - + {isLoading ? ( + + ) : error ? ( + + ) : ( +
+ {sessions.slice(0, 10).map((s) => { + const isEp = s.series_name && s.season_number != null && s.episode_number != null; + const title = isEp + ? s.episode_name || `S${s.season_number}E${s.episode_number}` + : s.media_title || `File #${s.media_file_id}`; + const username = s.username || `User #${s.user_id}`; + return ( +
+
+
-
- {getTimeAgo(s.started_at)} +
+
+ {username} + {" started watching "} + + {title} + +
+
+ {getTimeAgo(s.started_at)} +
+
+
+
-
- -
-
- ); - })} -
+ ); + })} +
+ )} ); @@ -614,3 +700,43 @@ function getTimeAgo(dateStr: string): string { const days = Math.floor(hours / 24); return `${days}d ago`; } + +function SectionError({ message }: { message: string }) { + return
{message}
; +} + +function LibrarySkeletonRows() { + return ( + <> + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ); +} + +function UserSkeletonRows() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); +} + +function ActivitySkeletonRows() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} diff --git a/web/src/pages/AdminStats.tsx b/web/src/pages/AdminStats.tsx index 112a7154..db2da80a 100644 --- a/web/src/pages/AdminStats.tsx +++ b/web/src/pages/AdminStats.tsx @@ -9,14 +9,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; import { Film, FileVideo, Users, Play } from "lucide-react"; +import type { AdminSession, AdminStats } from "@/api/types"; export default function AdminStats() { - const { data: stats, isLoading: statsLoading } = useAdminStats(); - const { data: sessions = [], isLoading: sessionsLoading } = useAdminSessions(); - const loading = statsLoading || sessionsLoading; - - if (loading) return
Loading stats...
; + const statsQuery = useAdminStats(); + const sessionsQuery = useAdminSessions(); + const sessions = sessionsQuery.data ?? []; return (
@@ -30,64 +30,107 @@ export default function AdminStats() {
- {stats && ( -
- } - /> - } - /> - } /> - } - /> + + + +
+ ); +} + +function StatsCards({ + stats, + sessionCount, + isLoading, + error, +}: { + stats: AdminStats | undefined; + sessionCount: number; + isLoading: boolean; + error: unknown; +}) { + if (error) { + return ; + } + if (isLoading || !stats) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + return ( +
+ } /> + } /> + } /> + } /> +
+ ); +} + +function SessionsSection({ + sessions, + isLoading, + error, +}: { + sessions: AdminSession[]; + isLoading: boolean; + error: unknown; +}) { + return ( +
+

Active playback sessions

+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : error ? ( + + ) : sessions.length === 0 ? ( +
+ No active sessions. +
+ ) : ( +
+
+ + + Session ID + User ID + File ID + Method + Started + + + + {sessions.map((s) => ( + + {s.session_id.slice(0, 8)}... + {s.user_id} + {s.media_file_id} + {s.play_method} + + {new Date(s.started_at).toLocaleString()} + + + ))} + +
)} - -
-

Active playback sessions

- {sessions.length === 0 ? ( -
- No active sessions. -
- ) : ( -
- - - - Session ID - User ID - File ID - Method - Started - - - - {sessions.map((s) => ( - - - {s.session_id.slice(0, 8)}... - - {s.user_id} - {s.media_file_id} - {s.play_method} - - {new Date(s.started_at).toLocaleString()} - - - ))} - -
-
- )} -
); } @@ -105,3 +148,11 @@ function StatCard({ title, value, icon }: { title: string; value: number; icon: ); } + +function SectionError({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +}