Replace font-bold, font-black, and font-semibold with font-normal throughout: - 26 component files - 1 CSS utility file (globals.css) - 291 total occurrences Text hierarchy now maintained through font-size differences only. All tests passing (291/291). Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
195 lines
8.7 KiB
TypeScript
195 lines
8.7 KiB
TypeScript
'use client';
|
|
|
|
import { RefreshCw, XCircle, Search } from 'lucide-react';
|
|
|
|
interface CameraViewProps {
|
|
scannerId: string;
|
|
isStarted: boolean;
|
|
paused?: boolean;
|
|
error: string | null;
|
|
hasZoom: boolean;
|
|
zoom: number;
|
|
maxZoom: number;
|
|
onZoomChange: (newZoom: number) => Promise<void>;
|
|
countdown: number;
|
|
ocrProcessing: boolean;
|
|
isSelecting: boolean;
|
|
capturedImage: string | null;
|
|
detectedWords: Array<{ text: string; bbox: { x0: number; y0: number; x1: number; y1: number } }>;
|
|
onScan?: () => void;
|
|
onWordSelect: (text: string) => void;
|
|
onCancelSelection: () => void;
|
|
}
|
|
|
|
export default function CameraView({
|
|
scannerId,
|
|
isStarted,
|
|
paused,
|
|
error,
|
|
hasZoom,
|
|
zoom,
|
|
maxZoom,
|
|
onZoomChange,
|
|
countdown,
|
|
ocrProcessing,
|
|
isSelecting,
|
|
capturedImage,
|
|
detectedWords,
|
|
onScan,
|
|
onWordSelect,
|
|
onCancelSelection,
|
|
}: CameraViewProps) {
|
|
const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
|
|
|
|
return (
|
|
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
|
|
{/* Video Viewport Area */}
|
|
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-background border-2 border-slate-800/50 p-4 sm:p-6">
|
|
<div className="absolute inset-4 sm:inset-6 z-10 pointer-events-none flex items-center justify-center">
|
|
<div className="w-full h-full border border-primary/30 rounded-[2rem] relative">
|
|
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
|
<div className="absolute top-0 right-0 w-10 h-10 border-t-4 border-r-4 border-primary rounded-tr-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
|
<div className="absolute bottom-0 left-0 w-10 h-10 border-b-4 border-l-4 border-primary rounded-bl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
|
<div className="absolute bottom-0 right-0 w-10 h-10 border-b-4 border-r-4 border-primary rounded-br-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
|
|
|
{isStarted && !paused && !isSelecting && (
|
|
<div className="absolute top-0 left-0 right-0 h-1 bg-primary animate-scan-fast" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div id={scannerId} data-testid="camera-indicator" className="w-full h-full bg-surface object-cover" />
|
|
|
|
{/* Selection UI */}
|
|
{isSelecting && capturedImage && (
|
|
<div className="absolute inset-0 z-50 bg-background flex flex-col">
|
|
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
|
|
<img
|
|
src={capturedImage || undefined}
|
|
className="max-w-full max-h-full object-contain"
|
|
id="ocr-canvas-preview"
|
|
alt="OCR text detection preview with detected words highlighted"
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="relative" style={{ width: '100%', height: '100%' }}>
|
|
{detectedWords.map((w, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => onWordSelect(w.text)}
|
|
aria-label={`Select text: ${w.text}`}
|
|
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 cursor-pointer transition-colors pointer-events-auto focus:ring-2 focus:ring-primary focus:outline-none"
|
|
style={{
|
|
left: `${(w.bbox.x0 / 1600) * 100}%`,
|
|
top: `${(w.bbox.y0 / (1600 * (9 / 16))) * 100}%`,
|
|
width: `${((w.bbox.x1 - w.bbox.x0) / 1600) * 100}%`,
|
|
height: `${((w.bbox.y1 - w.bbox.y0) / (1600 * (9 / 16))) * 100}%`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 bg-surface/90 border-t border-slate-800 flex flex-col gap-4">
|
|
<div className="flex flex-col items-center gap-1">
|
|
<p className="text-sm font-normal text-white italic text-center">Text Found</p>
|
|
<p className="text-xs text-muted font-normal text-center">Tap any text to use it</p>
|
|
</div>
|
|
<button
|
|
onClick={onCancelSelection}
|
|
aria-label="Cancel text selection"
|
|
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-normal text-xs cursor-pointer transition-all active:scale-95 border border-slate-700 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isStarted && !error && (
|
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-surface text-secondary gap-4">
|
|
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
|
<p className="text-sm font-medium">Initializing camera...</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-surface text-secondary px-8 text-center gap-4">
|
|
<XCircle className="w-10 h-10 text-red-500" />
|
|
<div>
|
|
<p className="font-normal text-white">Camera Error</p>
|
|
<p className="text-xs text-secondary mt-1">{error}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
aria-label="Reload page and try again"
|
|
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-normal cursor-pointer hover:bg-slate-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* External Controls Area */}
|
|
<div className="flex flex-col gap-4 bg-surface/50 p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
|
|
<div className="flex items-center gap-3 w-full">
|
|
{hasZoom && (
|
|
<button
|
|
onClick={async () => {
|
|
let nextZoom = 1;
|
|
if (zoom === 1) nextZoom = Math.min(2, maxZoom);
|
|
else if (zoom < maxZoom / 2) nextZoom = Math.floor(maxZoom / 2);
|
|
else if (zoom < maxZoom) nextZoom = maxZoom;
|
|
else nextZoom = 1;
|
|
|
|
await onZoomChange(nextZoom);
|
|
}}
|
|
data-testid="zoom-control"
|
|
aria-label={`Zoom ${zoom.toFixed(1)}x`}
|
|
className="h-14 px-5 bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg cursor-pointer transition-all active:scale-95 shrink-0 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<span className="text-xs font-normal tabular-nums">{zoom.toFixed(1)}x</span>
|
|
<span className="text-xs text-primary font-normal tracking-tighter">Zoom</span>
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex-1 h-14 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
|
|
{ocrProcessing ? (
|
|
<>
|
|
<RefreshCw className="animate-spin text-primary" size={18} />
|
|
<span className="text-xs font-normal text-secondary leading-none">Analyzing</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Search
|
|
className={cn('text-secondary transition-colors group-hover:text-primary', !isStarted && 'opacity-20')}
|
|
size={18}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs text-muted font-normal leading-none">Smart Scan</span>
|
|
<span className="text-xs font-normal text-primary leading-tight tabular-nums">
|
|
{countdown === 0 ? 'Scanning' : `${countdown}s`}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Visual Progress Bar */}
|
|
<div
|
|
className="absolute bottom-0 left-0 h-0.5 bg-primary/40 transition-all duration-1000 ease-linear shadow-[0_0_10px_rgba(59,130,246,0.5)]"
|
|
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full flex justify-center items-center gap-2">
|
|
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
|
|
<p className="text-xs text-secondary font-normal">
|
|
Scanner active · Use zoom or tap scan
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|