Build [v.1.3.6]

This commit is contained in:
Daniel Bedeleanu
2026-04-11 17:14:22 +03:00
parent 775808506f
commit 704934165f
27 changed files with 738 additions and 685 deletions

View File

@@ -9,12 +9,16 @@ interface AIOnboardingProps {
onCancel: () => void;
onComplete: (itemData: any) => void;
categories: any[];
inventory: any[];
}
export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnboardingProps) {
export default function AIOnboarding({ onCancel, onComplete, categories, inventory }: AIOnboardingProps) {
const [image, setImage] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [extractedData, setExtractedData] = useState<any>(null);
// Extract unique item types for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
const cameraInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -201,10 +205,14 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
</div>
<input
value={extractedData.type || ''}
list="onboarding-types"
onChange={(e) => setExtractedData({...extractedData, type: e.target.value})}
className="bg-transparent w-full font-bold outline-none text-slate-200"
placeholder="e.g. SFP+"
/>
<datalist id="onboarding-types">
{existingTypes.map(t => <option key={t} value={t} />)}
</datalist>
</div>
</div>

View File

@@ -161,8 +161,8 @@ export default function AdminOverlay({
<p className="text-xs text-slate-500">Exit current session and return to identity check.</p>
<button
onClick={() => {
localStorage.removeItem('inventory_user');
window.location.reload();
import('@/lib/auth').then(m => m.clearAuth());
window.location.href = '/login';
}}
className="w-full bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border border-rose-500/20 font-black py-3 rounded-xl transition-all flex items-center justify-center gap-2"
>

View File

@@ -68,7 +68,7 @@ export default function BottomNav({
{/* Logout */}
<button
onClick={() => {
localStorage.removeItem('inventory_user');
import('@/lib/auth').then(m => m.clearAuth());
window.location.href = '/login';
}}
className="flex flex-col items-center gap-1 hover:text-rose-500 transition-colors"

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react';
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
import { Camera, RefreshCw, XCircle, Type, Search } from 'lucide-react';
import { RefreshCw, XCircle, Search } from 'lucide-react';
import { createWorker } from 'tesseract.js';
import { toast } from 'react-hot-toast';
@@ -16,8 +16,8 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
const [isStarted, setIsStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOCRMode, setIsOCRMode] = useState(false);
const [ocrProcessing, setOcrProcessing] = useState(false);
const [countdown, setCountdown] = useState(4);
const [zoom, setZoom] = useState(1);
const [maxZoom, setMaxZoom] = useState(1);
const [hasZoom, setHasZoom] = useState(false);
@@ -41,8 +41,8 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
}
const config = {
fps: 15, // Lower FPS for stability
qrbox: { width: 280, height: 280 },
fps: 15,
qrbox: { width: 320, height: 320 },
aspectRatio: 1.0,
formatsToSupport: [
Html5QrcodeSupportedFormats.QR_CODE,
@@ -52,7 +52,6 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.DATAMATRIX
],
// Simplified constraints for maximum compatibility
videoConstraints: {
facingMode: "environment"
}
@@ -67,7 +66,6 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
() => {} // Ignore frame errors
);
// Check for zoom capability via video track
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
const caps = track?.getCapabilities() as any;
@@ -103,20 +101,26 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
.catch(e => console.error("Stop failed", e));
}
};
}, [paused, onScanSuccess]); // Minimal deps
}, [paused, onScanSuccess]);
// Separate effect for OCR interval to avoid restarting scanner
// Automated OCR Countdown Timer
useEffect(() => {
let intervalId: any;
if (isOCRMode && isStarted && !paused) {
intervalId = setInterval(() => {
handleOCR();
}, 5000); // 5 seconds is safer
if (!isStarted || paused || isSelecting || ocrProcessing) {
return;
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [isOCRMode, isStarted, paused]);
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
handleOCR();
return 4; // Reset to 4
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [isStarted, paused, isSelecting, ocrProcessing]);
const handleOCR = async () => {
if (ocrProcessing) return;
@@ -126,11 +130,9 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
if (!video) return;
const canvas = document.createElement('canvas');
const vWidth = video.videoWidth || 1280;
const vHeight = video.videoHeight || 720;
// Digital Zoom/Crop: 60% of center
const cropFactor = 0.6;
const sw = vWidth * cropFactor;
const sh = vHeight * cropFactor;
@@ -140,48 +142,36 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
canvas.width = 1200;
canvas.height = (sh / sw) * 1200;
console.log(`OCR Capture: ${vWidth}x${vHeight} -> ${canvas.width}x${canvas.height}`);
const ctx = canvas.getContext('2d');
if (ctx) {
// More balanced filter for both paper and metal labels
ctx.filter = 'grayscale(100%) contrast(180%) brightness(105%)';
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
}
// Timeout protection for worker initialization (especially when offline for first run)
const workerPromise = createWorker('eng');
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout - check internet for first run")), 8000));
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout")), 8000));
const worker = await Promise.race([workerPromise, timeoutPromise]) as any;
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
// DIAGNOSTIC: Set captured image early so user can see what was sent to AI
setCapturedImage(dataUrl);
const result = await worker.recognize(dataUrl);
console.log("OCR Result Size:", result.data?.text?.length);
const data = result.data;
// FALLBACK: If we have text but no word coordinates
if (data && data.text && (!data.words || data.words.length === 0)) {
console.log("Fallback to direct match");
onOCRMatch(data.text);
onOCRMatch?.(data.text);
await worker.terminate();
setCapturedImage(null);
return;
}
if (!data || !data.words || data.words.length === 0) {
console.warn("OCR produced no text or words.");
toast.error("Label not readable. Try better light or zoom.");
await worker.terminate();
setCapturedImage(null);
return;
}
// Store words with their bounding boxes
const words = data.words.map((w: any) => ({
text: w.text,
bbox: w.bbox
@@ -192,155 +182,156 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
await worker.terminate();
} catch (err) {
console.error("OCR failed", err);
toast.error("OCR selection failed");
} finally {
setOcrProcessing(false);
}
};
const handleWordSelect = (text: string) => {
onOCRMatch(text);
// Reset selection state
onOCRMatch?.(text);
setIsSelecting(false);
setCapturedImage(null);
setDetectedWords([]);
setCountdown(4); // Restart countdown
};
const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
return (
<div className="relative w-full max-w-[340px] mx-auto overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
<div className="w-[280px] h-[280px] border-2 border-primary/50 rounded-3xl relative">
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
</div>
</div>
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
<div className="absolute top-4 right-4 z-30 flex gap-2">
<button
onClick={() => setIsOCRMode(!isOCRMode)}
className={cn(
"p-2 rounded-xl border backdrop-blur-md transition-all",
isOCRMode ? "bg-primary border-primary text-white shadow-lg" : "bg-slate-900/50 border-slate-700 text-slate-300"
)}
>
<Type size={18} />
</button>
</div>
{/* Selection UI */}
{isSelecting && capturedImage && (
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
<img src={capturedImage} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
{/* Render Bounding Boxes */}
<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={() => handleWordSelect(w.text)}
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
style={{
left: `${(w.bbox.x0 / 1600) * 100}%`,
top: `${(w.bbox.y0 / (1600 * (9/16))) * 100}%`, // Assuming 16:9 ratio, this may need adjustment based on real canvas aspect
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-slate-900 border-t border-slate-800 flex flex-col gap-4">
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
<button
onClick={() => { setIsSelecting(false); setCapturedImage(null); }}
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
>
Cancel & Rescan
</button>
<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-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
<div className="w-[320px] h-[320px] border-2 border-primary/50 rounded-3xl relative">
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
{isStarted && !paused && !isSelecting && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
)}
</div>
</div>
)}
{isOCRMode && (
<div className="absolute inset-0 z-20 pointer-events-none border-2 border-primary/30 rounded-[2rem] animate-pulse" />
)}
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
{!isStarted && !error && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 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-slate-900 text-slate-300 px-8 text-center gap-4">
<XCircle className="w-10 h-10 text-red-500" />
<div>
<p className="font-bold text-white">Camera Error</p>
<p className="text-xs text-slate-400 mt-1">{error}</p>
</div>
<button
onClick={() => window.location.reload()}
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
>
Try Again
</button>
</div>
)}
<div className="absolute bottom-6 left-0 right-0 z-30 flex flex-col items-center gap-4 px-6">
{hasZoom && (
<button
onClick={async () => {
// Cycle through: 1x -> 2x -> Max/2 -> Max
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;
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
if (track) {
await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any });
setZoom(nextZoom);
}
}}
className="bg-slate-900/90 backdrop-blur-md border border-slate-700 text-white w-14 h-14 rounded-full flex flex-col items-center justify-center shadow-2xl active:scale-95 transition-transform"
>
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
<span className="text-xs text-primary font-bold">Zoom</span>
</button>
)}
{isOCRMode ? (
<button
onClick={handleOCR}
disabled={ocrProcessing}
className={cn(
"w-full py-4 rounded-2xl font-bold text-sm flex items-center justify-center gap-3 shadow-2xl transition-all active:scale-95 border border-white/10",
ocrProcessing ? "bg-slate-800 text-slate-500" : "bg-primary text-white"
)}
>
{ocrProcessing ? <RefreshCw className="animate-spin" size={18} /> : <Search size={18} />}
{ocrProcessing ? "Analyzing..." : "Find by Label (OCR)"}
</button>
) : (
<div className="bg-black/40 backdrop-blur-sm py-2 px-6 rounded-full border border-white/5">
<p className="text-xs text-center text-slate-300 font-bold">
Center barcode for auto-scan
</p>
{/* Selection UI */}
{isSelecting && capturedImage && (
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
<img src={capturedImage} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
<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={() => handleWordSelect(w.text)}
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
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-slate-900 border-t border-slate-800 flex flex-col gap-4">
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
<button
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
>
Cancel & rescans
</button>
</div>
</div>
)}
{!isStarted && !error && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 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-slate-900 text-slate-300 px-8 text-center gap-4">
<XCircle className="w-10 h-10 text-red-500" />
<div>
<p className="font-bold text-white">Camera Error</p>
<p className="text-xs text-slate-400 mt-1">{error}</p>
</div>
<button
onClick={() => window.location.reload()}
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
>
Try Again
</button>
</div>
)}
</div>
{/* External Controls Area */}
<div className="flex flex-col gap-4 bg-slate-900/40 p-6 rounded-[2rem] border border-slate-800/50">
<div className="flex items-center gap-4 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;
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
if (track) {
await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any });
setZoom(nextZoom);
}
}}
className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95"
>
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
<span className="text-[10px] text-primary font-bold uppercase">Zoom</span>
</button>
)}
<div className="flex-1 h-16 bg-slate-800/50 border border-slate-700 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden">
{ocrProcessing ? (
<>
<RefreshCw className="animate-spin text-primary" size={20} />
<span className="text-sm font-bold text-slate-200">Analyzing labels...</span>
</>
) : (
<>
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
<div className="flex flex-col">
<span className="text-[10px] text-slate-500 font-bold leading-none uppercase">Label Scanning</span>
<span className="text-sm font-black text-primary leading-tight">
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
</span>
</div>
</>
)}
{/* Visual Progress Bar */}
<div
className="absolute bottom-0 left-0 h-1 bg-primary/30 transition-all duration-1000 ease-linear"
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
/>
</div>
</div>
<div className="w-full flex justify-center items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<p className="text-[10px] text-slate-500 font-bold">
Barcode auto-scan active
</p>
</div>
</div>
</div>
);