Files
Vault/app/admin/stats-client.tsx
T
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

299 lines
10 KiB
TypeScript

// app/admin/stats-client.tsx
'use client';
import React, { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
interface VideoStats {
id: string;
title: string;
playlistTitle: string;
uploaderName: string;
views: number;
totalViewers: number;
completions: number;
completionRate: string;
likes: number;
comments: number;
engagement: number;
avgPercentWatched: number;
totalSecondsWatched: number;
totalSegments: number;
durationSec: number | null;
createdAt: string;
}
export default function StatsClient() {
const [stats, setStats] = useState<VideoStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<keyof VideoStats>('views');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
const response = await fetch('/api/admin/stats');
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const handleSort = (column: keyof VideoStats) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('desc');
}
};
const sortedStats = [...stats].sort((a, b) => {
const aVal = a[sortBy];
const bVal = b[sortBy];
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortOrder === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
const aNum = typeof aVal === 'number' ? aVal : 0;
const bNum = typeof bVal === 'number' ? bVal : 0;
return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
});
const formatDuration = (seconds: number | null) => {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}h ${minutes}m ${secs}s`;
};
const SortHeader = ({ column, label }: { column: keyof VideoStats; label: string }) => (
<TableHead
className="cursor-pointer hover:bg-muted whitespace-nowrap"
onClick={() => handleSort(column)}
>
<div className="flex items-center gap-1">
{label}
{sortBy === column && (
<span className="text-xs">
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</TableHead>
);
if (error) {
return (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-semibold">Error loading stats</p>
<p className="text-sm">{error}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Video Statistics</h1>
<p className="text-muted-foreground">
Performance metrics for all videos in the system
</p>
</div>
<Badge variant="outline" className="text-sm">
{stats.length} Videos
</Badge>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Views
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.views, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Unique Viewers
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.totalViewers, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Engagements
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.engagement, 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground mt-1">
Likes + Comments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg. Completion Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(
stats.reduce((sum, s) => sum + parseFloat(s.completionRate), 0) /
(stats.length || 1)
).toFixed(1)}
%
</div>
</CardContent>
</Card>
</div>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Video Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full overflow-x-auto">
{loading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : stats.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No videos found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<SortHeader column="title" label="Title" />
<SortHeader column="uploaderName" label="Uploader" />
<SortHeader column="views" label="Unlocks" />
<SortHeader column="totalViewers" label="Viewers" />
<SortHeader column="completions" label="Completions" />
<SortHeader column="completionRate" label="Completion %" />
<SortHeader column="avgPercentWatched" label="Avg % Watched" />
<SortHeader column="engagement" label="Engagement" />
<SortHeader column="likes" label="Likes" />
<SortHeader column="comments" label="Comments" />
<SortHeader column="totalSecondsWatched" label="Total Seconds Watched" />
<SortHeader column="durationSec" label="Duration" />
</TableRow>
</TableHeader>
<TableBody>
{sortedStats.map((video) => (
<TableRow key={video.id}>
<TableCell className="font-medium whitespace-nowrap">
<div className="flex flex-col">
<span className="max-w-xs truncate">{video.title}</span>
<span className="text-xs text-muted-foreground">
{video.playlistTitle}
</span>
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{video.uploaderName}
</TableCell>
<TableCell className="text-sm font-medium">
{video.views}
</TableCell>
<TableCell className="text-sm font-medium">
{video.totalViewers}
</TableCell>
<TableCell className="text-sm">
{video.completions}
</TableCell>
<TableCell className="text-sm font-semibold">
<Badge
variant={
parseFloat(video.completionRate) >= 50
? 'default'
: parseFloat(video.completionRate) >= 25
? 'secondary'
: 'destructive'
}
>
{video.completionRate}%
</Badge>
</TableCell>
<TableCell className="text-sm">
{video.avgPercentWatched.toFixed(1)}%
</TableCell>
<TableCell className="text-sm font-bold">
{video.engagement}
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.likes}</Badge>
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.comments}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{video.totalSecondsWatched.toLocaleString()}s
</TableCell>
<TableCell className="text-sm whitespace-nowrap">
{formatDuration(video.durationSec)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
);
}