mirror of
https://github.com/MrRobotjs/MUM.git
synced 2025-12-16 22:44:16 -06:00
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:
@@ -86,3 +86,5 @@ test_*.py
|
||||
# Strange files found in root
|
||||
=*.txt
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAdminApi } from '../../hooks/useAdminApi';
|
||||
import { DashboardCard } from './DashboardLayout';
|
||||
import { IconRefresh, IconEye } from '@tabler/icons-react';
|
||||
import { IconRefresh, IconEye, IconServer, IconActivity } from '@tabler/icons-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
type ServerStatusResponse = {
|
||||
data: {
|
||||
@@ -36,71 +36,94 @@ export const ServerStatusCard = ({ onViewAll }: ServerStatusCardProps = {}) => {
|
||||
const servers = data?.data.servers ?? [];
|
||||
|
||||
return (
|
||||
<DashboardCard title="Server Health">
|
||||
{loading ? (
|
||||
<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" />
|
||||
Loading server status…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Failed to load server status: {error}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
<p className="text-3xl font-bold text-primary">{summary.total_servers}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Offline</p>
|
||||
<p className="text-3xl font-bold text-destructive">{summary.offline}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Server Health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<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" />
|
||||
Loading server status…
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.slice(0, 5).map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
className="flex items-center justify-between rounded-lg border bg-muted/50 px-3 py-2"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{server.name}{' '}
|
||||
<span className="text-xs uppercase text-muted-foreground">({server.service_type})</span>
|
||||
</span>
|
||||
<Badge variant={server.online ? 'success' : 'error'}>
|
||||
{server.online ? 'Online' : 'Offline'}
|
||||
</Badge>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Failed to load server status: {error}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<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-3xl font-bold text-primary">{summary.total_servers}</p>
|
||||
</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>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No server data available.</p>
|
||||
)}
|
||||
<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-3xl font-bold text-green-600 dark:text-green-500">{summary.online}</p>
|
||||
</div>
|
||||
<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-3xl font-bold text-destructive">{summary.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => mutate()}>
|
||||
<IconRefresh className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{onViewAll ? (
|
||||
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
||||
<IconEye className="mr-2 h-4 w-4" />
|
||||
View All
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
className="flex flex-col justify-between rounded-lg border bg-card p-4 text-card-foreground shadow-sm"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||
<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'}
|
||||
</Badge>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No server data available.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => mutate()}>
|
||||
<IconRefresh className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DashboardCard>
|
||||
{onViewAll ? (
|
||||
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
||||
<IconEye className="mr-2 h-4 w-4" />
|
||||
View All
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DashboardLayout,
|
||||
ServerStatusCard,
|
||||
ActiveStreamsCard,
|
||||
WatchStatsCard,
|
||||
HistoryCard,
|
||||
ServersModal,
|
||||
InvitesSummaryCard,
|
||||
StreamingSummaryCard
|
||||
} from '../components/dashboard';
|
||||
import { DashboardLayout, ActiveStreamsCard, WatchStatsCard, HistoryCard, ServersModal, InvitesSummaryCard, StreamingSummaryCard, ServerStatusCard } from '../components/dashboard';
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const [serversModalOpen, setServersModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardLayout>
|
||||
{/* Top Row - Server Status */}
|
||||
<ServerStatusCard onViewAll={() => setServersModalOpen(true)} />
|
||||
|
||||
{/* Second Row - Watch Stats (Full Width) */}
|
||||
<WatchStatsCard />
|
||||
|
||||
{/* Third Row - Split View */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<InvitesSummaryCard />
|
||||
<StreamingSummaryCard />
|
||||
</div>
|
||||
|
||||
{/* Fourth Row - Split View */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<ActiveStreamsCard />
|
||||
<HistoryCard />
|
||||
<div className="space-y-6">
|
||||
<ServerStatusCard onViewAll={() => setServersModalOpen(true)} />
|
||||
<WatchStatsCard />
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<InvitesSummaryCard />
|
||||
<StreamingSummaryCard />
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ActiveStreamsCard />
|
||||
<HistoryCard />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
<ServersModal open={serversModalOpen} onClose={() => setServersModalOpen(false)} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLibraries, type Library } from '../hooks/useLibraries';
|
||||
import { useServers, type Server } from '../hooks/useServers';
|
||||
import { FormField, PageHeader } from '../components';
|
||||
import { PageHeader } from '../components';
|
||||
import { useAlerts } from '../contexts';
|
||||
import { requestJson } from '../util/apiClient';
|
||||
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 { Button } from '../components/ui/button';
|
||||
import { ResponsiveDialog } from '../components/ui/responsive-dialog';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
|
||||
type LibraryGroup = {
|
||||
serviceType: string;
|
||||
@@ -96,7 +98,7 @@ const getServiceMeta = (serviceType?: string) => {
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '—';
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch (err) {
|
||||
@@ -161,6 +163,28 @@ export const LibrariesPage = () => {
|
||||
return servers;
|
||||
}, [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 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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -308,7 +340,7 @@ export const LibrariesPage = () => {
|
||||
>
|
||||
{syncingAll ? (
|
||||
<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 className="flex items-center gap-2">
|
||||
@@ -320,69 +352,145 @@ export const LibrariesPage = () => {
|
||||
/>
|
||||
|
||||
{(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" />
|
||||
<span>
|
||||
{error
|
||||
? `Failed to load libraries: ${(error as Error).message}`
|
||||
: 'Loading server information…'}
|
||||
: 'Loading server information...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-filter">Server</Label>
|
||||
<Select
|
||||
value={serverId?.toString() || 'all'}
|
||||
onValueChange={(value) => setServerId(value === 'all' ? undefined : Number(value))}
|
||||
>
|
||||
<SelectTrigger id="server-filter">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Servers</SelectItem>
|
||||
{servers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id.toString()}>
|
||||
{server.server_nickname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type-filter">Library Type</Label>
|
||||
<Select value={libraryType || 'all'} onValueChange={(value) => setLibraryType(value === 'all' ? '' : value)}>
|
||||
<SelectTrigger id="type-filter">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{uniqueTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Search</Label>
|
||||
<Input
|
||||
id="search"
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search library name…"
|
||||
/>
|
||||
</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="space-y-2">
|
||||
<Label htmlFor="server-filter">Server</Label>
|
||||
<Select
|
||||
value={serverId?.toString() || 'all'}
|
||||
onValueChange={(value) => setServerId(value === 'all' ? undefined : Number(value))}
|
||||
>
|
||||
<SelectTrigger id="server-filter">
|
||||
<SelectValue placeholder="All servers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Servers</SelectItem>
|
||||
{servers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id.toString()}>
|
||||
{server.server_nickname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type-filter">Library Type</Label>
|
||||
<Select
|
||||
value={libraryType || 'all'}
|
||||
onValueChange={(value) => setLibraryType(value === 'all' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger id="type-filter">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{uniqueTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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
|
||||
id="search"
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search library name"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
{loading ? (
|
||||
<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>
|
||||
) : groups.length === 0 ? (
|
||||
<Card>
|
||||
@@ -391,114 +499,175 @@ export const LibrariesPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Card
|
||||
key={group.serviceType}
|
||||
className={cn(
|
||||
'border shadow-sm',
|
||||
`bg-gradient-to-br ${group.gradient}`
|
||||
)}
|
||||
>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
groups.map((group) => {
|
||||
const totalGroupLibraries = group.servers.reduce((acc, item) => acc + item.libraries.length, 0);
|
||||
return (
|
||||
<Card
|
||||
key={group.serviceType}
|
||||
className="overflow-hidden border bg-card/80 shadow-lg backdrop-blur"
|
||||
>
|
||||
<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 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}
|
||||
</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>
|
||||
<span className={cn('inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset', group.badgeClass)}>
|
||||
{group.servers.reduce((acc, item) => acc + item.libraries.length, 0)} libraries
|
||||
</span>
|
||||
<Badge className={cn('w-fit text-[11px] font-medium ring-1 ring-inset', group.badgeClass)}>
|
||||
{totalGroupLibraries} libraries
|
||||
</Badge>
|
||||
</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)}` : ''}
|
||||
<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">·</span>
|
||||
<span>
|
||||
{server.last_sync_at
|
||||
? `Last sync ${formatDateTime(server.last_sync_at)}`
|
||||
: 'Waiting for first sync'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleSyncServer(server.id)}
|
||||
disabled={syncingServerId === server.id}
|
||||
>
|
||||
{syncingServerId === server.id ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-b-2 border-current" /> Syncing
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-sync" /> Sync
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</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>
|
||||
{server.id !== -1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleSyncServer(server.id)}
|
||||
disabled={syncingServerId === server.id}
|
||||
>
|
||||
{syncingServerId === server.id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current" /> Syncing…
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-sync" /> Sync Server
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serverLibraries.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Library</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Items</TableHead>
|
||||
<TableHead>Last Scanned</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serverLibraries.map((library) => {
|
||||
return (
|
||||
{serverLibraries.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[200px]">Library</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Items</TableHead>
|
||||
<TableHead>Last Scanned</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serverLibraries.map((library) => (
|
||||
<TableRow key={library.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/admin/libraries/${library.id}?tab=overview`}
|
||||
className="text-primary font-medium hover:underline underline-offset-4"
|
||||
>
|
||||
{library.name}
|
||||
</Link>
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
to={`/admin/libraries/${library.id}?tab=overview`}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
{library.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ID {library.external_id}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center rounded-md border px-2 py-1 text-xs font-medium capitalize">
|
||||
{library.library_type || 'Unknown'}
|
||||
</span>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{library.library_type?.replace(/_/g, ' ') || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatCount(library.item_count)}</TableCell>
|
||||
<TableCell className="font-semibold">{formatCount(library.item_count)}</TableCell>
|
||||
<TableCell>{formatDateTime(library.last_scanned)}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => openRawModal(library)}>
|
||||
<i className="fa-solid fa-code" />
|
||||
<span className="sr-only">View raw JSON</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No libraries available. Sync this server to fetch libraries.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No libraries available. Sync this server to fetch libraries.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{libraries.length > 0 && (
|
||||
@@ -514,7 +683,9 @@ export const LibrariesPage = () => {
|
||||
if (!value) closeRawModal();
|
||||
}}
|
||||
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={[
|
||||
<Button key="close" variant="outline" onClick={closeRawModal}>
|
||||
Close
|
||||
@@ -527,7 +698,7 @@ export const LibrariesPage = () => {
|
||||
>
|
||||
{rawLoading ? (
|
||||
<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>
|
||||
) : rawData ? (
|
||||
<pre className="max-h-96 overflow-auto rounded bg-muted/50 p-4 text-xs font-mono">
|
||||
|
||||
Reference in New Issue
Block a user