- BottleGrid: Implement blurred backdrop effect for bottle cards - Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline - Smart Scan: Native OCR for Android, Live Text fallback for iOS - OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history - Features: Add feature flags in src/config/features.ts - SQL: Add ocr_logs table migration - Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
256 lines
15 KiB
TypeScript
256 lines
15 KiB
TypeScript
export const dynamic = 'force-dynamic';
|
|
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-sm">
|
|
<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-sm">
|
|
<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-sm">
|
|
<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-sm">
|
|
<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-sm">
|
|
<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-sm">
|
|
<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 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 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 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 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 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 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>
|
|
);
|
|
}
|