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
|
# Strange files found in root
|
||||||
=*.txt
|
=*.txt
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,71 +36,94 @@ export const ServerStatusCard = ({ onViewAll }: ServerStatusCardProps = {}) => {
|
|||||||
const servers = data?.data.servers ?? [];
|
const servers = data?.data.servers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardCard title="Server Health">
|
<Card>
|
||||||
{loading ? (
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<CardTitle>Server Health</CardTitle>
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
</CardHeader>
|
||||||
Loading server status…
|
<CardContent>
|
||||||
</div>
|
{loading ? (
|
||||||
) : error ? (
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
Failed to load server status: {error}
|
Loading server status…
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
{servers.slice(0, 5).map((server) => (
|
Failed to load server status: {error}
|
||||||
<div
|
</div>
|
||||||
key={server.id}
|
) : summary ? (
|
||||||
className="flex items-center justify-between rounded-lg border bg-muted/50 px-3 py-2"
|
<div className="space-y-6">
|
||||||
>
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<span className="font-medium">
|
<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm space-y-1">
|
||||||
{server.name}{' '}
|
<p className="text-sm text-muted-foreground">Total</p>
|
||||||
<span className="text-xs uppercase text-muted-foreground">({server.service_type})</span>
|
<p className="text-3xl font-bold text-primary">{summary.total_servers}</p>
|
||||||
</span>
|
|
||||||
<Badge variant={server.online ? 'success' : 'error'}>
|
|
||||||
{server.online ? 'Online' : 'Offline'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm space-y-1">
|
||||||
{servers.length > 5 ? (
|
<p className="text-sm text-muted-foreground">Online</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-3xl font-bold text-green-600 dark:text-green-500">{summary.online}</p>
|
||||||
Showing first 5 of {servers.length} servers. View the servers page for more details.
|
</div>
|
||||||
</p>
|
<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm space-y-1">
|
||||||
) : null}
|
<p className="text-sm text-muted-foreground">Offline</p>
|
||||||
</div>
|
<p className="text-3xl font-bold text-destructive">{summary.offline}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">No server data available.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => mutate()}>
|
{servers.map((server) => (
|
||||||
<IconRefresh className="mr-2 h-4 w-4" />
|
<div
|
||||||
Refresh
|
key={server.id}
|
||||||
</Button>
|
className="flex flex-col justify-between rounded-lg border bg-card p-4 text-card-foreground shadow-sm"
|
||||||
{onViewAll ? (
|
>
|
||||||
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
<div className="space-y-3">
|
||||||
<IconEye className="mr-2 h-4 w-4" />
|
<div className="flex items-start justify-between">
|
||||||
View All
|
<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>
|
</Button>
|
||||||
) : null}
|
{onViewAll ? (
|
||||||
</div>
|
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
||||||
</DashboardCard>
|
<IconEye className="mr-2 h-4 w-4" />
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,23 @@
|
|||||||
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)} />
|
||||||
|
<WatchStatsCard />
|
||||||
{/* Second Row - Watch Stats (Full Width) */}
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<WatchStatsCard />
|
<InvitesSummaryCard />
|
||||||
|
<StreamingSummaryCard />
|
||||||
{/* Third Row - Split View */}
|
</div>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<InvitesSummaryCard />
|
<ActiveStreamsCard />
|
||||||
<StreamingSummaryCard />
|
<HistoryCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fourth Row - Split View */}
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<ActiveStreamsCard />
|
|
||||||
<HistoryCard />
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
<ServersModal open={serversModalOpen} onClose={() => setServersModalOpen(false)} />
|
<ServersModal open={serversModalOpen} onClose={() => setServersModalOpen(false)} />
|
||||||
|
|||||||
@@ -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,69 +352,145 @@ 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-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">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Label htmlFor="type-filter">Library Type</Label>
|
<Card className="border border-border/60 bg-gradient-to-br from-background via-background/40 to-muted/40 shadow-sm">
|
||||||
<Select value={libraryType || 'all'} onValueChange={(value) => setLibraryType(value === 'all' ? '' : value)}>
|
<CardContent className="space-y-2 p-5">
|
||||||
<SelectTrigger id="type-filter">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Libraries</p>
|
||||||
<SelectValue />
|
<div className="text-3xl font-semibold">{summaryStats.totalLibraries.toLocaleString()}</div>
|
||||||
</SelectTrigger>
|
<p className="text-sm text-muted-foreground">
|
||||||
<SelectContent>
|
{uniqueTypes.length} library type{uniqueTypes.length === 1 ? '' : 's'}
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
</p>
|
||||||
{uniqueTypes.map((type) => (
|
</CardContent>
|
||||||
<SelectItem key={type} value={type}>
|
</Card>
|
||||||
{type.replace(/_/g, ' ')}
|
<Card className="border border-border/60 bg-gradient-to-br from-background via-background/40 to-primary/5 shadow-sm">
|
||||||
</SelectItem>
|
<CardContent className="space-y-2 p-5">
|
||||||
))}
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Items Indexed</p>
|
||||||
</SelectContent>
|
<div className="text-3xl font-semibold">{summaryStats.totalItems.toLocaleString()}</div>
|
||||||
</Select>
|
<p className="text-sm text-muted-foreground">Across all libraries</p>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<div className="space-y-2">
|
<Card className="border border-border/60 bg-gradient-to-br from-background via-background/40 to-emerald-500/5 shadow-sm">
|
||||||
<Label htmlFor="search">Search</Label>
|
<CardContent className="space-y-2 p-5">
|
||||||
<Input
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</p>
|
||||||
id="search"
|
<div className="text-3xl font-semibold">{summaryStats.activeServers.toLocaleString()}</div>
|
||||||
type="text"
|
<Badge
|
||||||
value={search}
|
variant="outline"
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
className={cn(
|
||||||
placeholder="Search library name…"
|
'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'
|
||||||
</div>
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
</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 ? (
|
{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,114 +499,175 @@ export const LibrariesPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
groups.map((group) => (
|
|
||||||
<Card
|
groups.map((group) => {
|
||||||
key={group.serviceType}
|
const totalGroupLibraries = group.servers.reduce((acc, item) => acc + item.libraries.length, 0);
|
||||||
className={cn(
|
return (
|
||||||
'border shadow-sm',
|
<Card
|
||||||
`bg-gradient-to-br ${group.gradient}`
|
key={group.serviceType}
|
||||||
)}
|
className="overflow-hidden border bg-card/80 shadow-lg backdrop-blur"
|
||||||
>
|
>
|
||||||
<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
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CardContent className="space-y-6 bg-muted/20 p-6">
|
||||||
<div className="space-y-6">
|
{group.servers.map(({ server, libraries: serverLibraries }) => {
|
||||||
{group.servers.map(({ server, libraries: serverLibraries }) => (
|
const serverItemCount = serverLibraries.reduce((acc, library) => acc + (library.item_count ?? 0), 0);
|
||||||
<div key={server.id !== -1 ? server.id : `lib-${serverLibraries[0]?.id ?? 'unknown'}`}
|
const lastLibraryScan = serverLibraries.reduce<string | null>((latest, library) => {
|
||||||
className="rounded-lg border bg-background shadow-sm">
|
if (!library.last_scanned) return latest;
|
||||||
<div className="border-b px-4 py-3 flex flex-wrap items-center justify-between gap-3">
|
if (!latest) return library.last_scanned;
|
||||||
<div>
|
return new Date(library.last_scanned) > new Date(latest) ? library.last_scanned : latest;
|
||||||
<h3 className="text-lg font-semibold">{server.server_nickname}</h3>
|
}, null);
|
||||||
<div className="text-xs text-muted-foreground">
|
const isServerOnline = server.last_status === true;
|
||||||
{server.server_name || server.server_nickname}
|
return (
|
||||||
{server.last_sync_at ? ` • Last sync ${formatDateTime(server.last_sync_at)}` : ''}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{server.id !== -1 && (
|
{serverLibraries.length > 0 ? (
|
||||||
<Button
|
<div className="overflow-x-auto">
|
||||||
size="sm"
|
<Table>
|
||||||
variant="ghost"
|
<TableHeader>
|
||||||
onClick={() => handleSyncServer(server.id)}
|
<TableRow>
|
||||||
disabled={syncingServerId === server.id}
|
<TableHead className="min-w-[200px]">Library</TableHead>
|
||||||
>
|
<TableHead>Type</TableHead>
|
||||||
{syncingServerId === server.id ? (
|
<TableHead>Items</TableHead>
|
||||||
<span className="flex items-center gap-1">
|
<TableHead>Last Scanned</TableHead>
|
||||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current" /> Syncing…
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</span>
|
</TableRow>
|
||||||
) : (
|
</TableHeader>
|
||||||
<span className="flex items-center gap-1">
|
<TableBody>
|
||||||
<i className="fa-solid fa-sync" /> Sync Server
|
{serverLibraries.map((library) => (
|
||||||
</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 (
|
|
||||||
<TableRow key={library.id}>
|
<TableRow key={library.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<div className="space-y-1">
|
||||||
to={`/admin/libraries/${library.id}?tab=overview`}
|
<Link
|
||||||
className="text-primary font-medium hover:underline underline-offset-4"
|
to={`/admin/libraries/${library.id}?tab=overview`}
|
||||||
>
|
className="font-semibold text-primary hover:underline"
|
||||||
{library.name}
|
>
|
||||||
</Link>
|
{library.name}
|
||||||
|
</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>
|
) : (
|
||||||
) : (
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
No libraries available. Sync this server to fetch libraries.
|
||||||
No libraries available. Sync this server to fetch libraries.
|
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user