Revamp dashboard and libraries UI layout

Updated ServerStatusCard to use new Card components and improved server details display. Refactored DashboardPage layout for better responsiveness and grouping. Enhanced LibrariesPage with summary stats, improved filters, and modernized library/server cards and tables for a more informative and visually appealing experience.
This commit is contained in:
Christopher
2025-11-22 14:10:00 -07:00
parent 4c9ad6aaf6
commit 0938c14fdd
4 changed files with 416 additions and 233 deletions

View File

@@ -86,3 +86,5 @@ test_*.py
# Strange files found in root # Strange files found in root
=*.txt =*.txt

View File

@@ -1,8 +1,8 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useAdminApi } from '../../hooks/useAdminApi'; import { useAdminApi } from '../../hooks/useAdminApi';
import { DashboardCard } from './DashboardLayout'; import { IconRefresh, IconEye, IconServer, IconActivity } from '@tabler/icons-react';
import { IconRefresh, IconEye } from '@tabler/icons-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
type ServerStatusResponse = { type ServerStatusResponse = {
data: { data: {
@@ -36,7 +36,11 @@ export const ServerStatusCard = ({ onViewAll }: ServerStatusCardProps = {}) => {
const servers = data?.data.servers ?? []; const servers = data?.data.servers ?? [];
return ( return (
<DashboardCard title="Server Health"> <Card>
<CardHeader>
<CardTitle>Server Health</CardTitle>
</CardHeader>
<CardContent>
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
@@ -47,42 +51,60 @@ export const ServerStatusCard = ({ onViewAll }: ServerStatusCardProps = {}) => {
Failed to load server status: {error} Failed to load server status: {error}
</div> </div>
) : summary ? ( ) : summary ? (
<div className="space-y-4"> <div className="space-y-6">
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="space-y-1"> <div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm space-y-1">
<p className="text-sm text-muted-foreground">Total</p> <p className="text-sm text-muted-foreground">Total</p>
<p className="text-3xl font-bold text-primary">{summary.total_servers}</p> <p className="text-3xl font-bold text-primary">{summary.total_servers}</p>
</div> </div>
<div className="space-y-1"> <div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm space-y-1">
<p className="text-sm text-muted-foreground">Online</p> <p className="text-sm text-muted-foreground">Online</p>
<p className="text-3xl font-bold text-green-600 dark:text-green-500">{summary.online}</p> <p className="text-3xl font-bold text-green-600 dark:text-green-500">{summary.online}</p>
</div> </div>
<div className="space-y-1"> <div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm space-y-1">
<p className="text-sm text-muted-foreground">Offline</p> <p className="text-sm text-muted-foreground">Offline</p>
<p className="text-3xl font-bold text-destructive">{summary.offline}</p> <p className="text-3xl font-bold text-destructive">{summary.offline}</p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{servers.slice(0, 5).map((server) => ( {servers.map((server) => (
<div <div
key={server.id} key={server.id}
className="flex items-center justify-between rounded-lg border bg-muted/50 px-3 py-2" className="flex flex-col justify-between rounded-lg border bg-card p-4 text-card-foreground shadow-sm"
> >
<span className="font-medium"> <div className="space-y-3">
{server.name}{' '} <div className="flex items-start justify-between">
<span className="text-xs uppercase text-muted-foreground">({server.service_type})</span> <div className="flex items-center gap-2">
</span> <div className="rounded-md bg-primary/10 p-2 text-primary">
<Badge variant={server.online ? 'success' : 'error'}> <IconServer className="h-4 w-4" />
</div>
<span className="font-semibold">{server.name}</span>
</div>
<Badge variant={server.online ? 'default' : 'destructive'}>
{server.online ? 'Online' : 'Offline'} {server.online ? 'Online' : 'Offline'}
</Badge> </Badge>
</div> </div>
<div className="space-y-1 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Type:</span>
<span className="font-medium text-foreground">{server.service_type}</span>
</div>
<div className="flex items-center justify-between">
<span>Version:</span>
<span className="font-medium text-foreground">{server.version ?? 'N/A'}</span>
</div>
</div>
</div>
{server.error_message && (
<div className="mt-3 rounded bg-destructive/10 p-2 text-xs text-destructive">
{server.error_message}
</div>
)}
</div>
))} ))}
{servers.length > 5 ? (
<p className="text-xs text-muted-foreground">
Showing first 5 of {servers.length} servers. View the servers page for more details.
</p>
) : null}
</div> </div>
</div> </div>
) : ( ) : (
@@ -101,6 +123,7 @@ export const ServerStatusCard = ({ onViewAll }: ServerStatusCardProps = {}) => {
</Button> </Button>
) : null} ) : null}
</div> </div>
</DashboardCard> </CardContent>
</Card>
); );
}; };

View File

@@ -1,37 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { import { DashboardLayout, ActiveStreamsCard, WatchStatsCard, HistoryCard, ServersModal, InvitesSummaryCard, StreamingSummaryCard, ServerStatusCard } from '../components/dashboard';
DashboardLayout,
ServerStatusCard,
ActiveStreamsCard,
WatchStatsCard,
HistoryCard,
ServersModal,
InvitesSummaryCard,
StreamingSummaryCard
} from '../components/dashboard';
export const DashboardPage = () => { export const DashboardPage = () => {
const [serversModalOpen, setServersModalOpen] = useState(false); const [serversModalOpen, setServersModalOpen] = useState(false);
return ( return (
<> <>
<DashboardLayout> <DashboardLayout>
{/* Top Row - Server Status */} <div className="space-y-6">
<ServerStatusCard onViewAll={() => setServersModalOpen(true)} /> <ServerStatusCard onViewAll={() => setServersModalOpen(true)} />
{/* Second Row - Watch Stats (Full Width) */}
<WatchStatsCard /> <WatchStatsCard />
<div className="grid gap-6 md:grid-cols-2">
{/* Third Row - Split View */}
<div className="grid gap-6 lg:grid-cols-2">
<InvitesSummaryCard /> <InvitesSummaryCard />
<StreamingSummaryCard /> <StreamingSummaryCard />
</div> </div>
<div className="grid gap-6 md:grid-cols-2">
{/* Fourth Row - Split View */}
<div className="grid gap-6 lg:grid-cols-2">
<ActiveStreamsCard /> <ActiveStreamsCard />
<HistoryCard /> <HistoryCard />
</div> </div>
</div>
</DashboardLayout> </DashboardLayout>
<ServersModal open={serversModalOpen} onClose={() => setServersModalOpen(false)} /> <ServersModal open={serversModalOpen} onClose={() => setServersModalOpen(false)} />
</> </>

View File

@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useLibraries, type Library } from '../hooks/useLibraries'; import { useLibraries, type Library } from '../hooks/useLibraries';
import { useServers, type Server } from '../hooks/useServers'; import { useServers, type Server } from '../hooks/useServers';
import { FormField, PageHeader } from '../components'; import { PageHeader } from '../components';
import { useAlerts } from '../contexts'; import { useAlerts } from '../contexts';
import { requestJson } from '../util/apiClient'; import { requestJson } from '../util/apiClient';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
@@ -13,6 +13,8 @@ import { Card, CardContent } from '../components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { ResponsiveDialog } from '../components/ui/responsive-dialog'; import { ResponsiveDialog } from '../components/ui/responsive-dialog';
import { Badge } from '../components/ui/badge';
import { Separator } from '../components/ui/separator';
type LibraryGroup = { type LibraryGroup = {
serviceType: string; serviceType: string;
@@ -96,7 +98,7 @@ const getServiceMeta = (serviceType?: string) => {
}; };
const formatDateTime = (value?: string | null) => { const formatDateTime = (value?: string | null) => {
if (!value) return ''; if (!value) return '-';
try { try {
return new Date(value).toLocaleString(); return new Date(value).toLocaleString();
} catch (err) { } catch (err) {
@@ -161,6 +163,28 @@ export const LibrariesPage = () => {
return servers; return servers;
}, [servers, serverId]); }, [servers, serverId]);
const summaryStats = useMemo(() => {
const totalLibraries = libraries.length;
const totalItems = libraries.reduce((acc, lib) => acc + (lib.item_count ?? 0), 0);
const activeServers = servers.length;
const onlineServers = servers.filter((server) => server.last_status === true).length;
const lastSyncedAt = servers.reduce<string | null>((latest, server) => {
if (!server.last_sync_at) return latest;
if (!latest) return server.last_sync_at;
return new Date(server.last_sync_at) > new Date(latest) ? server.last_sync_at : latest;
}, null);
return {
totalLibraries,
totalItems,
activeServers,
onlineServers,
lastSyncedAt
};
}, [libraries, servers]);
const lastSyncDisplay = summaryStats.lastSyncedAt ? formatDateTime(summaryStats.lastSyncedAt) : 'Not available';
const groups = useMemo<LibraryGroup[]>(() => { const groups = useMemo<LibraryGroup[]>(() => {
const grouped = new Map<string, LibraryGroup>(); const grouped = new Map<string, LibraryGroup>();
@@ -294,6 +318,14 @@ export const LibrariesPage = () => {
} }
}; };
const filtersApplied = Boolean(serverId || libraryType || search);
const handleClearFilters = () => {
setServerId(undefined);
setLibraryType('');
setSearch('');
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -308,7 +340,7 @@ export const LibrariesPage = () => {
> >
{syncingAll ? ( {syncingAll ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current" /> Syncing <div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current" /> Syncing...
</span> </span>
) : ( ) : (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -320,16 +352,82 @@ export const LibrariesPage = () => {
/> />
{(error || serversLoading) && ( {(error || serversLoading) && (
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-sm text-yellow-400 flex items-center gap-2"> <div className="flex items-center gap-2 rounded-xl border border-yellow-500/20 bg-yellow-500/10 p-4 text-sm text-yellow-400">
<i className="fa-solid fa-circle-info" /> <i className="fa-solid fa-circle-info" />
<span> <span>
{error {error
? `Failed to load libraries: ${(error as Error).message}` ? `Failed to load libraries: ${(error as Error).message}`
: 'Loading server information'} : 'Loading server information...'}
</span> </span>
</div> </div>
)} )}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border border-border/60 bg-gradient-to-br from-background via-background/40 to-muted/40 shadow-sm">
<CardContent className="space-y-2 p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Libraries</p>
<div className="text-3xl font-semibold">{summaryStats.totalLibraries.toLocaleString()}</div>
<p className="text-sm text-muted-foreground">
{uniqueTypes.length} library type{uniqueTypes.length === 1 ? '' : 's'}
</p>
</CardContent>
</Card>
<Card className="border border-border/60 bg-gradient-to-br from-background via-background/40 to-primary/5 shadow-sm">
<CardContent className="space-y-2 p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Items Indexed</p>
<div className="text-3xl font-semibold">{summaryStats.totalItems.toLocaleString()}</div>
<p className="text-sm text-muted-foreground">Across all libraries</p>
</CardContent>
</Card>
<Card className="border border-border/60 bg-gradient-to-br from-background via-background/40 to-emerald-500/5 shadow-sm">
<CardContent className="space-y-2 p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</p>
<div className="text-3xl font-semibold">{summaryStats.activeServers.toLocaleString()}</div>
<Badge
variant="outline"
className={cn(
'w-fit border-emerald-400/60 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
summaryStats.onlineServers === 0 && 'border-amber-400/60 bg-amber-500/10 text-amber-600'
)}
>
<span className="flex items-center gap-1">
<span
className={cn(
'h-1.5 w-1.5 rounded-full bg-emerald-500',
summaryStats.onlineServers === 0 && 'bg-amber-500'
)}
/>
{summaryStats.onlineServers} online
</span>
</Badge>
</CardContent>
</Card>
<Card className="border border-border/60 bg-gradient-to-br from-background via-background/40 to-secondary/20 shadow-sm">
<CardContent className="space-y-2 p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Most Recent Sync</p>
<div className="text-xl font-semibold leading-tight">{lastSyncDisplay}</div>
<p className="text-sm text-muted-foreground">
Keep your data current by syncing libraries regularly.
</p>
</CardContent>
</Card>
</div>
<Card className="border shadow-sm">
<CardContent className="space-y-5 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">Filters</p>
<p className="text-xs text-muted-foreground">Narrow down libraries by server, type, or keyword.</p>
</div>
<Button variant="ghost" size="sm" onClick={handleClearFilters} disabled={!filtersApplied}>
<span className="flex items-center gap-2">
<i className="fa-solid fa-sliders" /> Reset
</span>
</Button>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="server-filter">Server</Label> <Label htmlFor="server-filter">Server</Label>
@@ -338,7 +436,7 @@ export const LibrariesPage = () => {
onValueChange={(value) => setServerId(value === 'all' ? undefined : Number(value))} onValueChange={(value) => setServerId(value === 'all' ? undefined : Number(value))}
> >
<SelectTrigger id="server-filter"> <SelectTrigger id="server-filter">
<SelectValue /> <SelectValue placeholder="All servers" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Servers</SelectItem> <SelectItem value="all">All Servers</SelectItem>
@@ -353,9 +451,12 @@ export const LibrariesPage = () => {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type-filter">Library Type</Label> <Label htmlFor="type-filter">Library Type</Label>
<Select value={libraryType || 'all'} onValueChange={(value) => setLibraryType(value === 'all' ? '' : value)}> <Select
value={libraryType || 'all'}
onValueChange={(value) => setLibraryType(value === 'all' ? '' : value)}
>
<SelectTrigger id="type-filter"> <SelectTrigger id="type-filter">
<SelectValue /> <SelectValue placeholder="All types" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Types</SelectItem> <SelectItem value="all">All Types</SelectItem>
@@ -370,19 +471,26 @@ export const LibrariesPage = () => {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search">Search</Label> <Label htmlFor="search">Search</Label>
<div className="relative">
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground" />
<Input <Input
id="search" id="search"
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search library name" placeholder="Search library name"
className="pl-9"
/> />
</div> </div>
</div> </div>
</div>
</CardContent>
</Card>
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" /> Loading libraries <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" /> Loading libraries...
</div> </div>
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<Card> <Card>
@@ -391,100 +499,160 @@ export const LibrariesPage = () => {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
groups.map((group) => (
groups.map((group) => {
const totalGroupLibraries = group.servers.reduce((acc, item) => acc + item.libraries.length, 0);
return (
<Card <Card
key={group.serviceType} key={group.serviceType}
className={cn( className="overflow-hidden border bg-card/80 shadow-lg backdrop-blur"
'border shadow-sm',
`bg-gradient-to-br ${group.gradient}`
)}
> >
<CardContent className="space-y-4"> <div className={cn('flex flex-wrap items-center justify-between gap-4 border-b px-6 py-4 bg-gradient-to-r', group.gradient)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background shadow-inner"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-background/90 shadow-inner ring-1 ring-black/5 dark:bg-background/60">
{group.icon} {group.icon}
</div> </div>
<div> <div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">Service</p>
<h2 className="text-xl font-semibold">{group.label}</h2> <h2 className="text-xl font-semibold">{group.label}</h2>
<span className={cn('inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset', group.badgeClass)}> <Badge className={cn('w-fit text-[11px] font-medium ring-1 ring-inset', group.badgeClass)}>
{group.servers.reduce((acc, item) => acc + item.libraries.length, 0)} libraries {totalGroupLibraries} libraries
</Badge>
</div>
</div>
</div>
<CardContent className="space-y-6 bg-muted/20 p-6">
{group.servers.map(({ server, libraries: serverLibraries }) => {
const serverItemCount = serverLibraries.reduce((acc, library) => acc + (library.item_count ?? 0), 0);
const lastLibraryScan = serverLibraries.reduce<string | null>((latest, library) => {
if (!library.last_scanned) return latest;
if (!latest) return library.last_scanned;
return new Date(library.last_scanned) > new Date(latest) ? library.last_scanned : latest;
}, null);
const isServerOnline = server.last_status === true;
return (
<div
key={server.id !== -1 ? server.id : `lib-${serverLibraries[0]?.id ?? 'unknown'}`}
className="rounded-2xl border bg-card/90 text-card-foreground shadow-sm ring-1 ring-border/40"
>
<div className="flex flex-wrap items-center justify-between gap-4 px-4 py-4">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">{server.server_nickname}</h3>
{server.id !== -1 && (
<Badge variant="secondary" className="capitalize">
{server.service_type || 'Unknown'}
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{server.server_name || server.server_nickname}</span>
<span className="hidden sm:inline">&middot;</span>
<span>
{server.last_sync_at
? `Last sync ${formatDateTime(server.last_sync_at)}`
: 'Waiting for first sync'}
</span> </span>
</div> </div>
</div> </div>
</div>
<div className="space-y-6">
{group.servers.map(({ server, libraries: serverLibraries }) => (
<div key={server.id !== -1 ? server.id : `lib-${serverLibraries[0]?.id ?? 'unknown'}`}
className="rounded-lg border bg-background shadow-sm">
<div className="border-b px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold">{server.server_nickname}</h3>
<div className="text-xs text-muted-foreground">
{server.server_name || server.server_nickname}
{server.last_sync_at ? ` • Last sync ${formatDateTime(server.last_sync_at)}` : ''}
</div>
</div>
{server.id !== -1 && ( {server.id !== -1 && (
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className={cn(
'border-emerald-400/60 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
!isServerOnline && 'border-destructive/40 bg-destructive/10 text-destructive'
)}
>
<span className="flex items-center gap-1">
<span
className={cn(
'h-1.5 w-1.5 rounded-full bg-emerald-500',
!isServerOnline && 'bg-destructive'
)}
/>
{isServerOnline ? 'Online' : 'Offline'}
</span>
</Badge>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="outline"
onClick={() => handleSyncServer(server.id)} onClick={() => handleSyncServer(server.id)}
disabled={syncingServerId === server.id} disabled={syncingServerId === server.id}
> >
{syncingServerId === server.id ? ( {syncingServerId === server.id ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current" /> Syncing <div className="h-3 w-3 animate-spin rounded-full border-b-2 border-current" /> Syncing
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1"> <span className="flex items-center gap-2">
<i className="fa-solid fa-sync" /> Sync Server <i className="fa-solid fa-sync" /> Sync
</span> </span>
)} )}
</Button> </Button>
</div>
)} )}
</div> </div>
<Separator />
<div className="grid gap-3 px-4 py-4 sm:grid-cols-3">
<div className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs uppercase text-muted-foreground">Libraries</p>
<p className="text-lg font-semibold">{serverLibraries.length}</p>
</div>
<div className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs uppercase text-muted-foreground">Items</p>
<p className="text-lg font-semibold">{formatCount(serverItemCount)}</p>
</div>
<div className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs uppercase text-muted-foreground">Latest Scan</p>
<p className="text-sm font-medium">
{lastLibraryScan ? formatDateTime(lastLibraryScan) : 'Not available'}
</p>
</div>
</div>
{serverLibraries.length > 0 ? ( {serverLibraries.length > 0 ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Library</TableHead> <TableHead className="min-w-[200px]">Library</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Items</TableHead> <TableHead>Items</TableHead>
<TableHead>Last Scanned</TableHead> <TableHead>Last Scanned</TableHead>
<TableHead>Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{serverLibraries.map((library) => { {serverLibraries.map((library) => (
return (
<TableRow key={library.id}> <TableRow key={library.id}>
<TableCell> <TableCell>
<div className="space-y-1">
<Link <Link
to={`/admin/libraries/${library.id}?tab=overview`} to={`/admin/libraries/${library.id}?tab=overview`}
className="text-primary font-medium hover:underline underline-offset-4" className="font-semibold text-primary hover:underline"
> >
{library.name} {library.name}
</Link> </Link>
<p className="text-xs text-muted-foreground">
ID {library.external_id}
</p>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="inline-flex items-center rounded-md border px-2 py-1 text-xs font-medium capitalize"> <Badge variant="secondary" className="capitalize">
{library.library_type || 'Unknown'} {library.library_type?.replace(/_/g, ' ') || 'Unknown'}
</span> </Badge>
</TableCell> </TableCell>
<TableCell>{formatCount(library.item_count)}</TableCell> <TableCell className="font-semibold">{formatCount(library.item_count)}</TableCell>
<TableCell>{formatDateTime(library.last_scanned)}</TableCell> <TableCell>{formatDateTime(library.last_scanned)}</TableCell>
<TableCell> <TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => openRawModal(library)}> <Button size="sm" variant="ghost" onClick={() => openRawModal(library)}>
<i className="fa-solid fa-code" /> <i className="fa-solid fa-code" />
<span className="sr-only">View raw JSON</span>
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
); ))}
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
@@ -494,11 +662,12 @@ export const LibrariesPage = () => {
</div> </div>
)} )}
</div> </div>
))} );
</div> })}
</CardContent> </CardContent>
</Card> </Card>
)) );
})
)} )}
{libraries.length > 0 && ( {libraries.length > 0 && (
@@ -514,7 +683,9 @@ export const LibrariesPage = () => {
if (!value) closeRawModal(); if (!value) closeRawModal();
}} }}
title="Library Raw Data" title="Library Raw Data"
description={rawLibrary ? `${rawLibrary.name}${rawLibrary.server?.server_nickname ?? 'Unknown server'}` : undefined} description={
rawLibrary ? `${rawLibrary.name} - ${rawLibrary.server?.server_nickname ?? 'Unknown server'}` : undefined
}
footer={[ footer={[
<Button key="close" variant="outline" onClick={closeRawModal}> <Button key="close" variant="outline" onClick={closeRawModal}>
Close Close
@@ -527,7 +698,7 @@ export const LibrariesPage = () => {
> >
{rawLoading ? ( {rawLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" /> Loading <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" /> Loading...
</div> </div>
) : rawData ? ( ) : rawData ? (
<pre className="max-h-96 overflow-auto rounded bg-muted/50 p-4 text-xs font-mono"> <pre className="max-h-96 overflow-auto rounded bg-muted/50 p-4 text-xs font-mono">