Files
Dramlog-Prod/src/app/admin/ocr-logs/page.tsx
robin 5c00be59f1 feat: Add UX optimizations - skeletons and optimistic hooks
- Add Skeletons.tsx with TastingListSkeleton, ChartSkeleton, etc.
- Add useOptimistic.ts hooks for React 19 optimistic updates
- Update stats page to use skeleton loading instead of spinner
- Remove force-dynamic exports (12 files) for SSG compatibility
- Note: PPR (cacheComponents) tested but reverted - requires RSC-first refactor
2026-01-19 23:01:00 +01:00

255 lines
15 KiB
TypeScript

import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import { getOcrLogs, getOcrStats } from '@/services/save-ocr-log';
import { Eye, Camera, TrendingUp, CheckCircle, AlertCircle, Calendar, Clock, Percent } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
export default async function OcrLogsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch OCR data
const [logsResult, stats] = await Promise.all([
getOcrLogs(100),
getOcrStats(),
]);
const logs = logsResult.data || [];
return (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">OCR Dashboard</h1>
<p className="text-zinc-500 mt-1">Mobile OCR Scan Results</p>
</div>
<div className="flex gap-3">
<Link
href="/admin"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
>
Back to Admin
</Link>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Camera size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Total Scans</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.totalScans}</div>
<div className="text-xs text-zinc-500 mt-1">All time</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Today</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.todayScans}</div>
<div className="text-xs text-zinc-500 mt-1">Scans today</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<Percent size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Avg Confidence</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.avgConfidence}%</div>
<div className="text-xs text-zinc-500 mt-1">Recognition quality</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Top Distillery</span>
</div>
<div className="text-xl font-black text-zinc-900 dark:text-white truncate">
{stats.topDistilleries[0]?.name || '-'}
</div>
<div className="text-xs text-zinc-500 mt-1">
{stats.topDistilleries[0] ? `${stats.topDistilleries[0].count} scans` : 'No data'}
</div>
</div>
</div>
{/* Top Distilleries */}
{stats.topDistilleries.length > 0 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Most Scanned Distilleries</h2>
<div className="flex flex-wrap gap-2">
{stats.topDistilleries.map((d, i) => (
<span
key={d.name}
className={`px-3 py-1.5 rounded-full text-sm font-bold ${i === 0
? 'bg-orange-600 text-white'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300'
}`}
>
{d.name} ({d.count})
</span>
))}
</div>
</div>
)}
{/* OCR Logs Grid */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent OCR Scans</h2>
{logs.length === 0 ? (
<div className="text-center py-12 text-zinc-500">
<Camera className="mx-auto mb-3" size={48} />
<p>No OCR scans recorded yet</p>
<p className="text-sm mt-1">Scans from mobile devices will appear here</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{logs.map((log: any) => (
<div
key={log.id}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700 hover:border-orange-500/50 transition-colors"
>
{/* Image Preview */}
<div className="relative aspect-4/3 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3">
{log.image_thumbnail ? (
<img
src={log.image_thumbnail}
alt="Scan"
className="w-full h-full object-cover"
/>
) : log.image_url ? (
<img
src={log.image_url}
alt="Scan"
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center h-full text-zinc-400">
<Camera size={32} />
</div>
)}
{/* Confidence Badge */}
<div className={`absolute top-2 right-2 px-2 py-1 rounded-full text-[10px] font-black ${log.confidence >= 70
? 'bg-green-500 text-white'
: log.confidence >= 40
? 'bg-amber-500 text-white'
: 'bg-red-500 text-white'
}`}>
{log.confidence}%
</div>
</div>
{/* Detected Fields */}
<div className="space-y-2">
{log.distillery && (
<div className="flex items-center gap-2">
<CheckCircle size={14} className="text-green-500" />
<span className="text-sm font-bold text-zinc-900 dark:text-white">
{log.distillery}
</span>
{log.distillery_source && (
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-sm text-zinc-500">
{log.distillery_source}
</span>
)}
</div>
)}
{log.bottle_name && (
<div className="text-sm text-zinc-600 dark:text-zinc-400 truncate">
{log.bottle_name}
</div>
)}
<div className="flex flex-wrap gap-1.5">
{log.abv && (
<span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded-sm text-[10px] font-bold">
{log.abv}%
</span>
)}
{log.age && (
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-sm text-[10px] font-bold">
{log.age}y
</span>
)}
{log.vintage && (
<span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-sm text-[10px] font-bold">
{log.vintage}
</span>
)}
{log.volume && (
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-sm text-[10px] font-bold">
{log.volume}
</span>
)}
</div>
</div>
{/* Raw Text (Collapsible) */}
{log.raw_text && (
<details className="mt-3">
<summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase">
Raw Text
</summary>
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-900 rounded-sm text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap">
{log.raw_text}
</pre>
</details>
)}
{/* Meta */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-1 text-[10px] text-zinc-400">
<Clock size={12} />
{new Date(log.created_at).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
<div className="text-[10px] text-zinc-400">
{log.profiles?.username || 'Unknown'}
</div>
{log.processing_time_ms && (
<div className="text-[10px] text-zinc-400">
{log.processing_time_ms}ms
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</main>
);
}