From 9260784ef8b121830e5a8dbe91ffbafd3f8d7b04 Mon Sep 17 00:00:00 2001 From: krishnendu07-code Date: Fri, 12 Jun 2026 13:45:58 +0530 Subject: [PATCH 1/2] fix: allow silent scan submission --- src/lib/api.ts | 146 +++++++++----- src/pages/ScannerPage.tsx | 415 ++++++++++++++++++++++---------------- 2 files changed, 345 insertions(+), 216 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index b553505..19160f1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,18 +1,20 @@ -import toast from 'react-hot-toast'; +import toast from "react-hot-toast"; import type { ScanResult, HistoryScan, HistoryStats, Market, UserProfile, -} from './types'; +} from "./types"; // Base URL — override with VITE_API_URL in .env for production -const API_BASE = (import.meta.env.VITE_API_URL as string | undefined) || 'http://localhost:8000'; +const API_BASE = + (import.meta.env.VITE_API_URL as string | undefined) || + "http://localhost:8000"; // ── Token management ────────────────────────────────────────────────────────── -const TOKEN_KEY = 'fs_access_token'; +const TOKEN_KEY = "fs_access_token"; export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); @@ -20,12 +22,12 @@ export function getToken(): string | null { export function setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token); - window.dispatchEvent(new Event('auth-change')); + window.dispatchEvent(new Event("auth-change")); } export function clearToken(): void { localStorage.removeItem(TOKEN_KEY); - window.dispatchEvent(new Event('auth-change')); + window.dispatchEvent(new Event("auth-change")); } export function isAuthenticated(): boolean { @@ -51,27 +53,38 @@ async function handleResponse(res: Response): Promise { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error((err as { detail?: string }).detail || `HTTP ${res.status}`); } - -async function safeFetch(input: RequestInfo | URL, init?: RequestInit): Promise { +type ApiRequestOptions = { + silent?: boolean; +}; +async function safeFetch( + input: RequestInfo | URL, + init?: RequestInit, + options?: ApiRequestOptions, +): Promise { try { const res = await fetch(input, init); return await handleResponse(res); } catch (error) { - if (error instanceof TypeError) { - toast.error("Unable to connect to the server. Please check your internet connection."); + if (error instanceof TypeError && !options?.silent) { + toast.error( + "Unable to connect to the server. Please check your internet connection.", + ); } + console.error("API Error:", error); throw error; } } - -async function apiFetch(path: string, options: RequestInit = {}): Promise { +async function apiFetch( + path: string, + options: RequestInit = {}, +): Promise { const validRes = await safeFetch(`${API_BASE}${path}`, { ...options, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...authHeaders(), - ...(options.headers as Record || {}), + ...((options.headers as Record) || {}), }, }); return validRes.json() as Promise; @@ -79,17 +92,33 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise // ── Response envelopes ──────────────────────────────────────────────────────── -export interface ScanResponse { success: boolean; scan: ScanResult; } -export interface HistoryResponse { success: boolean; count: number; stats: HistoryStats; scans: HistoryScan[]; } -export interface MarketsResponse { success: boolean; markets: Market[]; } -export interface GradcamResponse { gradcam_image: string; predicted_class: string; class_index: number; mode: 'real' | 'demo'; } +export interface ScanResponse { + success: boolean; + scan: ScanResult; +} +export interface HistoryResponse { + success: boolean; + count: number; + stats: HistoryStats; + scans: HistoryScan[]; +} +export interface MarketsResponse { + success: boolean; + markets: Market[]; +} +export interface GradcamResponse { + gradcam_image: string; + predicted_class: string; + class_index: number; + mode: "real" | "demo"; +} // Metadata sent alongside edge-inference results so the backend can store them // without re-running the ML pipeline on the server. export interface EdgeInferenceMeta { freshness_label?: string; - fused_score?: number; - source?: 'edge_onnx' | 'server'; + fused_score?: number; + source?: "edge_onnx" | "server"; } // ── API surface ─────────────────────────────────────────────────────────────── @@ -97,35 +126,48 @@ export interface EdgeInferenceMeta { export const api = { loginUrl: async (turnstileToken?: string): Promise => { if (turnstileToken) { - const response = await apiFetch<{ redirect_url: string }>('/api/v1/auth/login/google', { - method: 'POST', - body: JSON.stringify({ turnstile_token: turnstileToken }), - }); + const response = await apiFetch<{ redirect_url: string }>( + "/api/v1/auth/login/google", + { + method: "POST", + body: JSON.stringify({ turnstile_token: turnstileToken }), + }, + ); return response.redirect_url; } return `${API_BASE}/api/v1/auth/login/google`; }, - getMe: (): Promise => apiFetch('/api/v1/auth/me'), + getMe: (): Promise => apiFetch("/api/v1/auth/me"), // ── Scans ──────────────────────────────────────────────────────────────── // meta is optional — when provided (edge inference path), the backend skips // running its own ML pipeline and just stores the result we computed locally. - submitScan: async (blob: Blob, meta?: EdgeInferenceMeta): Promise => { + submitScan: async ( + blob: Blob, + meta?: EdgeInferenceMeta, + options?: ApiRequestOptions, + ): Promise => { const form = new FormData(); - form.append('image', blob, 'scan.jpg'); + form.append("image", blob, "scan.jpg"); // Attach edge inference metadata if available - if (meta?.freshness_label) form.append('freshness_label', meta.freshness_label); - if (meta?.fused_score !== undefined) form.append('fused_score', String(meta.fused_score)); - if (meta?.source) form.append('source', meta.source); - - const validRes = await safeFetch(`${API_BASE}/api/v1/scan-auto`, { - method: 'POST', - headers: authHeaders(), - body: form, - }); + if (meta?.freshness_label) + form.append("freshness_label", meta.freshness_label); + if (meta?.fused_score !== undefined) + form.append("fused_score", String(meta.fused_score)); + if (meta?.source) form.append("source", meta.source); + + const validRes = await safeFetch( + `${API_BASE}/api/v1/scan-auto`, + { + method: "POST", + headers: authHeaders(), + body: form, + }, + options, + ); return validRes.json() as Promise; }, @@ -138,16 +180,18 @@ export const api = { */ scanOnline: async (blob: Blob): Promise => { const form = new FormData(); - form.append('image', blob, 'scan.jpg'); + form.append("image", blob, "scan.jpg"); try { const res = await fetch(`${API_BASE}/api/v1/scan-auto`, { - method: 'POST', + method: "POST", headers: authHeaders(), body: form, }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error((err as { detail?: string }).detail || `HTTP ${res.status}`); + throw new Error( + (err as { detail?: string }).detail || `HTTP ${res.status}`, + ); } return res.json() as Promise; } catch (err) { @@ -160,21 +204,23 @@ export const api = { }, getLatestScan: (): Promise => - apiFetch('/api/v1/scans/latest'), + apiFetch("/api/v1/scans/latest"), getScan: (id: string): Promise => apiFetch(`/api/v1/scans/${id}`), getScanHistory: (limit = 20, offset = 0): Promise => - apiFetch(`/api/v1/scans/history?limit=${limit}&offset=${offset}`), + apiFetch( + `/api/v1/scans/history?limit=${limit}&offset=${offset}`, + ), // ── Grad-CAM ───────────────────────────────────────────────────────────── getGradcam: async (blob: Blob): Promise => { const form = new FormData(); - form.append('image', blob, 'gradcam_input.jpg'); + form.append("image", blob, "gradcam_input.jpg"); const validRes = await safeFetch(`${API_BASE}/api/v1/gradcam`, { - method: 'POST', + method: "POST", headers: authHeaders(), body: form, }); @@ -183,8 +229,14 @@ export const api = { }, getMarkets: (): Promise => - apiFetch('/api/v1/maps/markets'), - - getLiveMarkets: (lat: number, lng: number, radius = 15000): Promise => - apiFetch(`/api/v1/maps/markets/live?lat=${lat}&lng=${lng}&radius=${radius}`), + apiFetch("/api/v1/maps/markets"), + + getLiveMarkets: ( + lat: number, + lng: number, + radius = 15000, + ): Promise => + apiFetch( + `/api/v1/maps/markets/live?lat=${lat}&lng=${lng}&radius=${radius}`, + ), }; diff --git a/src/pages/ScannerPage.tsx b/src/pages/ScannerPage.tsx index c9e09e1..e89648d 100644 --- a/src/pages/ScannerPage.tsx +++ b/src/pages/ScannerPage.tsx @@ -1,24 +1,32 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Camera, Zap, RotateCcw, FlashlightOff, Flashlight, SwitchCamera, Upload } from 'lucide-react'; -import StatusTerminal from '../components/StatusTerminal'; -import { api, isAuthenticated } from '../lib/api'; -import { FishFreshnessInference } from '../fusionInference.js'; -import type { ScanResult } from '../lib/types'; +import { useState, useEffect, useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Camera, + Zap, + RotateCcw, + FlashlightOff, + Flashlight, + SwitchCamera, + Upload, +} from "lucide-react"; +import StatusTerminal from "../components/StatusTerminal"; +import { api, isAuthenticated } from "../lib/api"; +import { FishFreshnessInference } from "../fusionInference.js"; +import type { ScanResult } from "../lib/types"; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── -type ScanPhase = 'idle' | 'processing' | 'done' | 'error'; -type InferenceMode = 'cloud' | 'edge' | null; +type ScanPhase = "idle" | "processing" | "done" | "error"; +type InferenceMode = "cloud" | "edge" | null; /** Normalised result — maps both HF backend and ONNX outputs to one shape. */ interface DisplayResult { - label: 'Fresh' | 'Moderate' | 'Spoiled'; - freshness: number; // 0–100 integer - grade: string; // A+, A, B, C, D (cloud) or derived (edge) - confidence: string; // formatted percentage string + label: "Fresh" | "Moderate" | "Spoiled"; + freshness: number; // 0–100 integer + grade: string; // A+, A, B, C, D (cloud) or derived (edge) + confidence: string; // formatted percentage string } // ───────────────────────────────────────────────────────────────────────────── @@ -26,17 +34,17 @@ interface DisplayResult { // ───────────────────────────────────────────────────────────────────────────── function deriveGrade(freshness: number): string { - if (freshness >= 92) return 'A+'; - if (freshness >= 80) return 'A'; - if (freshness >= 65) return 'B'; - if (freshness >= 50) return 'C'; - return 'D'; + if (freshness >= 92) return "A+"; + if (freshness >= 80) return "A"; + if (freshness >= 65) return "B"; + if (freshness >= 50) return "C"; + return "D"; } function labelColor(label: string) { - if (label === 'Fresh') return 'text-neon'; - if (label === 'Moderate') return 'text-secondary'; - return 'text-error'; + if (label === "Fresh") return "text-neon"; + if (label === "Moderate") return "text-secondary"; + return "text-error"; } // ───────────────────────────────────────────────────────────────────────────── @@ -50,9 +58,12 @@ let engineLoading = false; async function getEngine(): Promise { if (engineReady && engineInstance) return engineInstance; if (engineLoading) { - await new Promise(resolve => { + await new Promise((resolve) => { const poll = setInterval(() => { - if (engineReady) { clearInterval(poll); resolve(); } + if (engineReady) { + clearInterval(poll); + resolve(); + } }, 100); }); return engineInstance!; @@ -70,17 +81,17 @@ async function getEngine(): Promise { // ───────────────────────────────────────────────────────────────────────────── async function captureVideoBlob(video: HTMLVideoElement): Promise { - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth || 640; + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth || 640; canvas.height = video.videoHeight || 480; - canvas.getContext('2d')?.drawImage(video, 0, 0); - return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92)); + canvas.getContext("2d")?.drawImage(video, 0, 0); + return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.92)); } async function blobToImageElement(blob: Blob): Promise { return new Promise((resolve, reject) => { const img = new Image(); - img.onload = () => resolve(img); + img.onload = () => resolve(img); img.onerror = reject; img.src = URL.createObjectURL(blob); }); @@ -94,41 +105,51 @@ export default function ScannerPage() { const navigate = useNavigate(); // ── State ────────────────────────────────────────────────────────────────── - const [scanPhase, setScanPhase] = useState('idle'); + const [scanPhase, setScanPhase] = useState("idle"); const [inferenceMode, setInferenceMode] = useState(null); - const [result, setResult] = useState(null); - const [error, setError] = useState(''); - const [flashOn, setFlashOn] = useState(false); - const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment'); - const [progress, setProgress] = useState(0); - const [copied, setCopied] = useState(false); - const [previewUrl, setPreviewUrl] = useState(null); - - const videoRef = useRef(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [flashOn, setFlashOn] = useState(false); + const [facingMode, setFacingMode] = useState<"environment" | "user">( + "environment", + ); + const [progress, setProgress] = useState(0); + const [copied, setCopied] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + + const videoRef = useRef(null); const fileInputRef = useRef(null); - const progressRef = useRef | null>(null); - const streamRef = useRef(null); + const progressRef = useRef | null>(null); + const streamRef = useRef(null); // ── Pre-warm ONNX engine on mount (runs in background) ──────────────────── - useEffect(() => { getEngine().catch(console.error); }, []); + useEffect(() => { + getEngine().catch(console.error); + }, []); // ── Camera stream ────────────────────────────────────────────────────────── useEffect(() => { - if (scanPhase !== 'idle') return; + if (scanPhase !== "idle") return; let cancelled = false; const currentVideo = videoRef.current; - navigator.mediaDevices.getUserMedia({ video: { facingMode } }) - .then(stream => { - if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; } + navigator.mediaDevices + .getUserMedia({ video: { facingMode } }) + .then((stream) => { + if (cancelled) { + stream.getTracks().forEach((t) => t.stop()); + return; + } streamRef.current = stream; if (currentVideo) currentVideo.srcObject = stream; }) - .catch(err => { if (!cancelled) console.error('Camera error:', err); }); + .catch((err) => { + if (!cancelled) console.error("Camera error:", err); + }); return () => { cancelled = true; - streamRef.current?.getTracks().forEach(t => t.stop()); + streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; if (currentVideo) currentVideo.srcObject = null; }; @@ -138,7 +159,7 @@ export default function ScannerPage() { const startProgress = useCallback(() => { setProgress(0); progressRef.current = setInterval(() => { - setProgress(prev => prev >= 90 ? prev : prev + Math.random() * 5 + 1); + setProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 5 + 1)); }, 120); }, []); @@ -149,153 +170,196 @@ export default function ScannerPage() { // ── Stop camera ──────────────────────────────────────────────────────────── const stopCamera = useCallback(() => { - streamRef.current?.getTracks().forEach(t => t.stop()); + streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; }, []); // ── Core: run hybrid inference on a single blob ──────────────────────────── - const runScan = useCallback(async (blob: Blob) => { - setScanPhase('processing'); - startProgress(); - setError(''); - setInferenceMode(null); + const runScan = useCallback( + async (blob: Blob) => { + setScanPhase("processing"); + startProgress(); + setError(""); + setInferenceMode(null); + + // Store preview + const url = URL.createObjectURL(blob); + setPreviewUrl(url); + stopCamera(); + + try { + // ── Path A: online — try HF backend first ───────────────────────────── + const online = await api.scanOnline(blob); + + if (online) { + // Cloud inference succeeded — use typed ScanResult fields directly + const s: ScanResult = online.scan; + const freshness = s.freshness_index; + stopProgress(100); + setInferenceMode("cloud"); + setResult({ + label: s.is_fresh + ? freshness >= 80 + ? "Fresh" + : "Moderate" + : "Spoiled", + freshness, + grade: s.grade, + confidence: `${Math.round((s.confidence ?? 0.9) * 100)}%`, + }); - // Store preview - const url = URL.createObjectURL(blob); - setPreviewUrl(url); - stopCamera(); + if (s.scan_id) { + sessionStorage.setItem("lastScanId", s.scan_id); + } + setScanPhase("done"); + setTimeout(() => navigate("/analysis"), 1800); + return; + } - try { - // ── Path A: online — try HF backend first ───────────────────────────── - const online = await api.scanOnline(blob); + // ── Path B: offline — fall back to ONNX ─────────────────────────────── + setInferenceMode("edge"); + const imgEl = await blobToImageElement(blob); + const engine = await getEngine(); + const fusion = await engine.predictSingle(imgEl); - if (online) { - // Cloud inference succeeded — use typed ScanResult fields directly - const s: ScanResult = online.scan; - const freshness = s.freshness_index; stopProgress(100); - setInferenceMode('cloud'); + const freshness = Math.round(fusion.fusedScore * 100); setResult({ - label: s.is_fresh ? (freshness >= 80 ? 'Fresh' : 'Moderate') : 'Spoiled', + label: fusion.label, freshness, - grade: s.grade, - confidence: `${Math.round((s.confidence ?? 0.9) * 100)}%`, + grade: deriveGrade(freshness), + confidence: fusion.confidence, }); - - if (s.scan_id) { - sessionStorage.setItem('lastScanId', s.scan_id); - } - setScanPhase('done'); - setTimeout(() => navigate('/analysis'), 1800); - return; + setScanPhase("done"); + + // Best-effort backend save (non-blocking, offline-safe) + const canvas = document.createElement("canvas"); + canvas.width = 224; + canvas.height = 224; + canvas.getContext("2d")?.drawImage(imgEl, 0, 0, 224, 224); + canvas.toBlob( + async (saveBlob) => { + if (!saveBlob) return; + try { + const saved = await api.submitScan( + saveBlob, + { + freshness_label: fusion.label, + fused_score: fusion.fusedScore, + source: "edge_onnx", + }, + { silent: true }, + ); + if ((saved?.scan as unknown as { scan_id?: string })?.scan_id) { + sessionStorage.setItem( + "lastScanId", + (saved.scan as unknown as { scan_id: string }).scan_id, + ); + } + } catch { + /* offline or backend down — result still shown locally */ + } + }, + "image/jpeg", + 0.85, + ); + + setTimeout(() => navigate("/analysis"), 1800); + } catch (err) { + stopProgress(0); + const msg = err instanceof Error ? err.message : "Inference failed."; + const isNotFish = + msg.includes("NOT_A_FISH") || + msg.includes("not appear to contain a fish"); + setError( + isNotFish + ? "NOT_A_FISH: No fish detected. Please photograph a fish." + : msg, + ); + setScanPhase("error"); } - - // ── Path B: offline — fall back to ONNX ─────────────────────────────── - setInferenceMode('edge'); - const imgEl = await blobToImageElement(blob); - const engine = await getEngine(); - const fusion = await engine.predictSingle(imgEl); - - stopProgress(100); - const freshness = Math.round(fusion.fusedScore * 100); - setResult({ - label: fusion.label, - freshness, - grade: deriveGrade(freshness), - confidence: fusion.confidence, - }); - setScanPhase('done'); - - // Best-effort backend save (non-blocking, offline-safe) - const canvas = document.createElement('canvas'); - canvas.width = 224; canvas.height = 224; - canvas.getContext('2d')?.drawImage(imgEl, 0, 0, 224, 224); - canvas.toBlob(async saveBlob => { - if (!saveBlob) return; - try { - const saved = await api.submitScan(saveBlob, { - freshness_label: fusion.label, - fused_score: fusion.fusedScore, - source: 'edge_onnx', - }); - if ((saved?.scan as unknown as { scan_id?: string })?.scan_id) { - sessionStorage.setItem('lastScanId', (saved.scan as unknown as { scan_id: string }).scan_id); - } - } catch { /* offline or backend down — result still shown locally */ } - }, 'image/jpeg', 0.85); - - setTimeout(() => navigate('/analysis'), 1800); - - } catch (err) { - stopProgress(0); - const msg = err instanceof Error ? err.message : 'Inference failed.'; - const isNotFish = msg.includes('NOT_A_FISH') || msg.includes('not appear to contain a fish'); - setError(isNotFish ? 'NOT_A_FISH: No fish detected. Please photograph a fish.' : msg); - setScanPhase('error'); - } - }, [startProgress, stopProgress, stopCamera, navigate]); + }, + [startProgress, stopProgress, stopCamera, navigate], + ); // ── Camera capture ───────────────────────────────────────────────────────── const captureFrame = useCallback(async () => { - if (!isAuthenticated()) { navigate('/auth'); return; } + if (!isAuthenticated()) { + navigate("/auth"); + return; + } const video = videoRef.current; if (!video) return; const blob = await captureVideoBlob(video); - if (!blob) { setError('Failed to capture frame.'); return; } + if (!blob) { + setError("Failed to capture frame."); + return; + } await runScan(blob); }, [runScan, navigate]); // ── File upload ──────────────────────────────────────────────────────────── const handleUploadClick = useCallback(() => { - if (!isAuthenticated()) { navigate('/auth'); return; } + if (!isAuthenticated()) { + navigate("/auth"); + return; + } fileInputRef.current?.click(); }, [navigate]); - const handleFileChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - if (fileInputRef.current) fileInputRef.current.value = ''; - await runScan(file); - }, [runScan]); + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (fileInputRef.current) fileInputRef.current.value = ""; + await runScan(file); + }, + [runScan], + ); // ── Reset ────────────────────────────────────────────────────────────────── const resetScan = useCallback(() => { - setScanPhase('idle'); + setScanPhase("idle"); setResult(null); - setError(''); + setError(""); setInferenceMode(null); setProgress(0); - if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null); } + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } }, [previewUrl]); const toggleCamera = useCallback(() => { - setFacingMode(prev => prev === 'environment' ? 'user' : 'environment'); + setFacingMode((prev) => (prev === "environment" ? "user" : "environment")); }, []); // ── Derived ──────────────────────────────────────────────────────────────── - const isScanning = scanPhase === 'processing'; - const scanComplete = scanPhase === 'done'; - const freshness = result?.freshness ?? null; + const isScanning = scanPhase === "processing"; + const scanComplete = scanPhase === "done"; + const freshness = result?.freshness ?? null; const terminalMessages = (() => { - if (isScanning && inferenceMode === 'edge') return ['MODE: EDGE_ONNX', 'RUNNING_LOCAL_INFERENCE...']; - if (isScanning && inferenceMode === 'cloud') return ['MODE: CLOUD_API', 'CONNECTING_TO_HF...']; - if (isScanning) return ['DETECTING_MODEL...', 'PLEASE_WAIT']; - if (scanComplete && inferenceMode === 'edge') return ['MODEL: EDGE_ONNX', 'DEVICE: ON_DEVICE', 'LATENCY: <50ms']; - if (scanComplete && inferenceMode === 'cloud') return ['MODEL: CLOUD_API', 'DEVICE: HF_INFERENCE']; - if (scanPhase === 'error') return ['SCAN_SEQ: FAILED', 'CHECK_SPECIMEN']; - return ['SYSTEM: READY', 'POINT_CAMERA_AT_FISH']; + if (isScanning && inferenceMode === "edge") + return ["MODE: EDGE_ONNX", "RUNNING_LOCAL_INFERENCE..."]; + if (isScanning && inferenceMode === "cloud") + return ["MODE: CLOUD_API", "CONNECTING_TO_HF..."]; + if (isScanning) return ["DETECTING_MODEL...", "PLEASE_WAIT"]; + if (scanComplete && inferenceMode === "edge") + return ["MODEL: EDGE_ONNX", "DEVICE: ON_DEVICE", "LATENCY: <50ms"]; + if (scanComplete && inferenceMode === "cloud") + return ["MODEL: CLOUD_API", "DEVICE: HF_INFERENCE"]; + if (scanPhase === "error") return ["SCAN_SEQ: FAILED", "CHECK_SPECIMEN"]; + return ["SYSTEM: READY", "POINT_CAMERA_AT_FISH"]; })(); // ── Render ───────────────────────────────────────────────────────────────── return (
- {/* ── Viewport ──────────────────────────────────────────────────── */}
- {/* Preview or live camera */} {previewUrl && !isScanning ? ( )} @@ -321,7 +385,7 @@ export default function ScannerPage() { linear-gradient(rgba(195,244,0,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(195,244,0,0.3) 1px, transparent 1px) `, - backgroundSize: '40px 40px', + backgroundSize: "40px 40px", }} /> @@ -339,7 +403,7 @@ export default function ScannerPage() { )}
- {scanPhase === 'idle' && ( + {scanPhase === "idle" && ( <> POINT_AT_FISH @@ -353,7 +417,9 @@ export default function ScannerPage() { )} {scanComplete && result && (
- + {result.label.toUpperCase()} @@ -361,7 +427,7 @@ export default function ScannerPage() {
)} - {scanPhase === 'error' && ( + {scanPhase === "error" && ( {error} @@ -377,24 +443,30 @@ export default function ScannerPage() { {/* Mode badge — top-right */} {inferenceMode && (
- - {inferenceMode === 'edge' ? 'EDGE_ONNX' : 'CLOUD_API'} + + {inferenceMode === "edge" ? "EDGE_ONNX" : "CLOUD_API"}
)} {/* Flash toggle — only in idle */} - {scanPhase === 'idle' && ( + {scanPhase === "idle" && (
)} @@ -411,9 +483,8 @@ export default function ScannerPage() { {/* ── Controls panel ────────────────────────────────────────────── */}
- {/* Idle: capture buttons */} - {scanPhase === 'idle' && ( + {scanPhase === "idle" && (
)} @@ -471,13 +544,15 @@ export default function ScannerPage() { FRESHNESS_INDEX - + {result.freshness}
= 65 ? 'bg-neon' : result.freshness >= 35 ? 'bg-secondary' : 'bg-error'}`} + className={`h-full transition-all duration-700 ${result.freshness >= 65 ? "bg-neon" : result.freshness >= 35 ? "bg-secondary" : "bg-error"}`} style={{ width: `${result.freshness}%` }} />
@@ -485,7 +560,7 @@ export default function ScannerPage() {
)}
)} {/* Error state */} - {scanPhase === 'error' && ( + {scanPhase === "error" && (
{error} From c29f48d6b177527f03cb89d567f34985f000cdd3 Mon Sep 17 00:00:00 2001 From: krishnendu07-code Date: Fri, 12 Jun 2026 14:15:27 +0530 Subject: [PATCH 2/2] fix: respect silent mode for scan sync --- src/lib/api.ts | 13 ++++++++++--- src/pages/ScannerPage.tsx | 8 +++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 19160f1..2543436 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -41,12 +41,19 @@ function authHeaders(): Record { // ── Shared Error Handling Logic ────────────────────────────────────────────── -async function handleResponse(res: Response): Promise { +async function handleResponse( + res: Response, + options?: ApiRequestOptions, +): Promise { if (res.ok) return res; if (res.status >= 500) { const msg = "Server error. Please try again later."; - toast.error(msg); + + if (!options?.silent) { + toast.error(msg); + } + throw new Error(msg); } @@ -63,7 +70,7 @@ async function safeFetch( ): Promise { try { const res = await fetch(input, init); - return await handleResponse(res); + return await handleResponse(res, options); } catch (error) { if (error instanceof TypeError && !options?.silent) { toast.error( diff --git a/src/pages/ScannerPage.tsx b/src/pages/ScannerPage.tsx index e89648d..b4e4cb8 100644 --- a/src/pages/ScannerPage.tsx +++ b/src/pages/ScannerPage.tsx @@ -182,6 +182,7 @@ export default function ScannerPage() { startProgress(); setError(""); setInferenceMode(null); + sessionStorage.removeItem("lastScanId"); // Store preview const url = URL.createObjectURL(blob); @@ -251,11 +252,8 @@ export default function ScannerPage() { }, { silent: true }, ); - if ((saved?.scan as unknown as { scan_id?: string })?.scan_id) { - sessionStorage.setItem( - "lastScanId", - (saved.scan as unknown as { scan_id: string }).scan_id, - ); + if (saved?.scan?.scan_id) { + sessionStorage.setItem("lastScanId", saved.scan.scan_id); } } catch { /* offline or backend down — result still shown locally */