From a520b1ba2b2873e2d4bd5e21de41753267e97c53 Mon Sep 17 00:00:00 2001 From: Daniel Bedeleanu Date: Sun, 19 Apr 2026 12:31:21 +0300 Subject: [PATCH] refactor: extract useAIExtraction hook from AIOnboarding.tsx --- frontend/components/AIOnboarding.tsx | 242 ++++---------------------- frontend/hooks/useAIExtraction.ts | 248 +++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 213 deletions(-) create mode 100644 frontend/hooks/useAIExtraction.ts diff --git a/frontend/components/AIOnboarding.tsx b/frontend/components/AIOnboarding.tsx index eee39d2b..8e722b44 100644 --- a/frontend/components/AIOnboarding.tsx +++ b/frontend/components/AIOnboarding.tsx @@ -1,9 +1,9 @@ 'use client'; -import React, { useState, useRef, useEffect } from 'react'; +import React from 'react'; import { toast } from 'react-hot-toast'; import { Camera, Check, RefreshCw, X, Image as ImageIcon, Sparkles, Hash, Layout, Layers, Package, ChevronDown } from 'lucide-react'; -import { inventoryApi } from '@/lib/api'; +import { useAIExtraction } from '@/hooks/useAIExtraction'; interface AIOnboardingProps { onCancel: () => void; @@ -13,221 +13,37 @@ interface AIOnboardingProps { } export default function AIOnboarding({ onCancel, onComplete, categories, inventory }: AIOnboardingProps) { - const [image, setImage] = useState(null); - const [uploading, setUploading] = useState(false); - const [extractedItems, setExtractedItems] = useState([]); - const [editingIndex, setEditingIndex] = useState(null); - const [mode, setMode] = useState<'item' | 'box'>('item'); - const [isLive, setIsLive] = useState(false); - - const videoRef = useRef(null); - const canvasRef = useRef(null); - const streamRef = useRef(null); - - const startLiveCamera = async () => { - try { - setIsLive(true); - const stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } }, - audio: false - }); - if (videoRef.current) { - videoRef.current.srcObject = stream; - streamRef.current = stream; - } - } catch (err) { - console.error("Camera access error:", err); - toast.error("Could not access camera for live scan."); - setIsLive(false); - } - }; - - const stopLiveCamera = () => { - if (streamRef.current) { - streamRef.current.getTracks().forEach(track => track.stop()); - streamRef.current = null; - } - setIsLive(false); - }; - - const captureSnapshot = () => { - if (videoRef.current && canvasRef.current) { - const video = videoRef.current; - const canvas = canvasRef.current; - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - const dataUrl = canvas.toDataURL('image/jpeg', 0.85); - setImage(dataUrl); - stopLiveCamera(); - } - } - }; - - const processImage = async () => { - if (!image) return; - setUploading(true); - - try { - const blob = await (await fetch(image)).blob(); - const formData = new FormData(); - formData.append('file', blob, 'label.jpg'); - - const data = await inventoryApi.analyzeLabel(formData, mode); - - if (data.error) { - toast.error(`AI Error: ${data.error}`); - setUploading(false); - return; - } - - let parsedData = data; - if (typeof data === 'string') { - try { parsedData = JSON.parse(data); } catch (e) {} - } - - const d = parsedData; - - // HYPER-ROBUST: Find ANY array in the response if it's not a direct array - let items: any[] = []; - if (Array.isArray(d)) { - items = d; - } else { - const potentialArrayKey = Object.keys(d).find(k => Array.isArray(d[k])); - if (potentialArrayKey) { - items = d[potentialArrayKey]; - } else { - // Check for singular object (must have at least name or Item or PN) - const target = d.data || d; - if (target.name || target.Item || target.PartNr || target.part_number) { - items = [target]; - } - } - } - - if (!items || items.length === 0) { - toast.error("No relevant items detected. Try a closer photo."); - } else { - setExtractedItems(items); - if (items.length === 1) { - setEditingIndex(0); - toast.success("Item identified!"); - } else { - toast.success(`Found ${items.length} items!`); - } - } - } catch (error) { - toast.error("Failed to process image with AI"); - console.error(error); - } finally { - setUploading(false); - } - }; - - const confirmSingleItem = (index: number) => { - const data = extractedItems[index]; - const newItem = { - name: String(data.Item || data.name || "New AI Item"), - category: String(data.Category || data.category || "Uncategorized"), - type: data.Type || data.type ? String(data.Type || data.type) : null, - part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null, - color: data.Color || data.color ? String(data.Color || data.color) : null, - description: String(data.Description || data.description || ""), - connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null, - size: data.Size || data.size ? String(data.Size || data.size) : null, - ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null, - specs: String(data.specs || ""), - barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${index}`), - quantity: parseFloat(String(data.quantity || 1)), - min_quantity: 1.0, - box_label: data.box_label ? String(data.box_label) : null, - labels_data: JSON.stringify(data) - }; - onComplete(newItem); - - if (extractedItems.length > 1) { - const remaining = [...extractedItems]; - remaining.splice(index, 1); - setExtractedItems(remaining); - setEditingIndex(null); - } else { - setExtractedItems([]); - setEditingIndex(null); - } - }; + const { + image, + setImage, + uploading, + extractedItems, + setExtractedItems, + editingIndex, + setEditingIndex, + mode, + setMode, + isLive, + videoRef, + canvasRef, + fileInputRef, + existingTypes, + existingBoxes, + startLiveCamera, + stopLiveCamera, + captureSnapshot, + processImage, + confirmSingleItem, + confirmAllItems: hookConfirmAllItems, + updateEditingItem, + handleFileChange + } = useAIExtraction(inventory, onComplete); const confirmAllItems = async () => { - // Clone items and process them sequentially - const itemsToProcess = [...extractedItems]; - setUploading(true); - const toastId = toast.loading(`Adding ${itemsToProcess.length} items...`); - - try { - for (let i = 0; i < itemsToProcess.length; i++) { - const data = itemsToProcess[i]; - const newItem = { - name: String(data.Item || data.name || "New AI Item"), - category: String(data.Category || data.category || "Uncategorized"), - type: data.Type || data.type ? String(data.Type || data.type) : null, - part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null, - color: data.Color || data.color ? String(data.Color || data.color) : null, - description: String(data.Description || data.description || ""), - connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null, - size: data.Size || data.size ? String(data.Size || data.size) : null, - ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null, - specs: String(data.specs || ""), - barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${i}`), - quantity: parseFloat(String(data.quantity || 1)), - min_quantity: 1.0, - box_label: data.box_label ? String(data.box_label) : null, - labels_data: JSON.stringify(data) - }; - // Wait for parent to process each one - await onComplete(newItem); - } - toast.success(`Successfully added ${itemsToProcess.length} items`, { id: toastId }); - setExtractedItems([]); - onCancel(); // Close the modal after bulk completion - } catch (err) { - toast.error("Error during batch add", { id: toastId }); - } finally { - setUploading(false); - } + await hookConfirmAllItems(); + onCancel(); // Close modal after bulk completion }; - const updateEditingItem = (fields: any) => { - if (editingIndex === null) return; - const newItems = [...extractedItems]; - newItems[editingIndex] = { ...newItems[editingIndex], ...fields }; - setExtractedItems(newItems); - }; - - // Extract unique item types for suggestions - const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[]; - const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[]; - - const fileInputRef = useRef(null); - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onload = () => setImage(reader.result as string); - reader.readAsDataURL(file); - } - }; - - useEffect(() => { - // Cleanup on unmount - return () => { - if (streamRef.current) { - streamRef.current.getTracks().forEach(track => track.stop()); - } - }; - }, []); - return (
diff --git a/frontend/hooks/useAIExtraction.ts b/frontend/hooks/useAIExtraction.ts new file mode 100644 index 00000000..534a4ed7 --- /dev/null +++ b/frontend/hooks/useAIExtraction.ts @@ -0,0 +1,248 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { toast } from 'react-hot-toast'; +import { inventoryApi } from '@/lib/api'; +import { Item } from '@/lib/db'; + +export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) => void) { + const [image, setImage] = useState(null); + const [uploading, setUploading] = useState(false); + const [extractedItems, setExtractedItems] = useState([]); + const [editingIndex, setEditingIndex] = useState(null); + const [mode, setMode] = useState<'item' | 'box'>('item'); + const [isLive, setIsLive] = useState(false); + + const videoRef = useRef(null); + const canvasRef = useRef(null); + const streamRef = useRef(null); + const fileInputRef = useRef(null); + + const existingTypes = useMemo( + () => Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[], + [inventory] + ); + + const existingBoxes = useMemo( + () => Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[], + [inventory] + ); + + const startLiveCamera = async () => { + try { + setIsLive(true); + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } }, + audio: false + }); + if (videoRef.current) { + videoRef.current.srcObject = stream; + streamRef.current = stream; + } + } catch (err) { + console.error("Camera access error:", err); + toast.error("Could not access camera for live scan."); + setIsLive(false); + } + }; + + const stopLiveCamera = () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + } + setIsLive(false); + }; + + const captureSnapshot = () => { + if (videoRef.current && canvasRef.current) { + const video = videoRef.current; + const canvas = canvasRef.current; + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL('image/jpeg', 0.85); + setImage(dataUrl); + stopLiveCamera(); + } + } + }; + + const processImage = async () => { + if (!image) return; + setUploading(true); + + try { + const blob = await (await fetch(image)).blob(); + const formData = new FormData(); + formData.append('file', blob, 'label.jpg'); + + const data = await inventoryApi.analyzeLabel(formData, mode); + + if (data.error) { + toast.error(`AI Error: ${data.error}`); + setUploading(false); + return; + } + + let parsedData = data; + if (typeof data === 'string') { + try { parsedData = JSON.parse(data); } catch (e) {} + } + + const d = parsedData; + + // Find ANY array in the response if it's not a direct array + let items: any[] = []; + if (Array.isArray(d)) { + items = d; + } else { + const potentialArrayKey = Object.keys(d).find(k => Array.isArray(d[k])); + if (potentialArrayKey) { + items = d[potentialArrayKey]; + } else { + // Check for singular object (must have at least name or Item or PN) + const target = d.data || d; + if (target.name || target.Item || target.PartNr || target.part_number) { + items = [target]; + } + } + } + + if (!items || items.length === 0) { + toast.error("No relevant items detected. Try a closer photo."); + } else { + setExtractedItems(items); + if (items.length === 1) { + setEditingIndex(0); + toast.success("Item identified!"); + } else { + toast.success(`Found ${items.length} items!`); + } + } + } catch (error) { + toast.error("Failed to process image with AI"); + console.error(error); + } finally { + setUploading(false); + } + }; + + const confirmSingleItem = (index: number) => { + const data = extractedItems[index]; + const newItem = { + name: String(data.Item || data.name || "New AI Item"), + category: String(data.Category || data.category || "Uncategorized"), + type: data.Type || data.type ? String(data.Type || data.type) : null, + part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null, + color: data.Color || data.color ? String(data.Color || data.color) : null, + description: String(data.Description || data.description || ""), + connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null, + size: data.Size || data.size ? String(data.Size || data.size) : null, + ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null, + specs: String(data.specs || ""), + barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${index}`), + quantity: parseFloat(String(data.quantity || 1)), + min_quantity: 1.0, + box_label: data.box_label ? String(data.box_label) : null, + labels_data: JSON.stringify(data) + }; + onComplete(newItem); + + if (extractedItems.length > 1) { + const remaining = [...extractedItems]; + remaining.splice(index, 1); + setExtractedItems(remaining); + setEditingIndex(null); + } else { + setExtractedItems([]); + setEditingIndex(null); + } + }; + + const confirmAllItems = async () => { + const itemsToProcess = [...extractedItems]; + setUploading(true); + const toastId = toast.loading(`Adding ${itemsToProcess.length} items...`); + + try { + for (let i = 0; i < itemsToProcess.length; i++) { + const data = itemsToProcess[i]; + const newItem = { + name: String(data.Item || data.name || "New AI Item"), + category: String(data.Category || data.category || "Uncategorized"), + type: data.Type || data.type ? String(data.Type || data.type) : null, + part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null, + color: data.Color || data.color ? String(data.Color || data.color) : null, + description: String(data.Description || data.description || ""), + connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null, + size: data.Size || data.size ? String(data.Size || data.size) : null, + ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null, + specs: String(data.specs || ""), + barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${i}`), + quantity: parseFloat(String(data.quantity || 1)), + min_quantity: 1.0, + box_label: data.box_label ? String(data.box_label) : null, + labels_data: JSON.stringify(data) + }; + await onComplete(newItem); + } + toast.success(`Successfully added ${itemsToProcess.length} items`, { id: toastId }); + setExtractedItems([]); + } catch (err) { + toast.error("Error during batch add", { id: toastId }); + } finally { + setUploading(false); + } + }; + + const updateEditingItem = (fields: any) => { + if (editingIndex === null) return; + const newItems = [...extractedItems]; + newItems[editingIndex] = { ...newItems[editingIndex], ...fields }; + setExtractedItems(newItems); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = () => setImage(reader.result as string); + reader.readAsDataURL(file); + } + }; + + useEffect(() => { + return () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + } + }; + }, []); + + return { + image, + setImage, + uploading, + extractedItems, + setExtractedItems, + editingIndex, + setEditingIndex, + mode, + setMode, + isLive, + videoRef, + canvasRef, + fileInputRef, + existingTypes, + existingBoxes, + startLiveCamera, + stopLiveCamera, + captureSnapshot, + processImage, + confirmSingleItem, + confirmAllItems, + updateEditingItem, + handleFileChange + }; +}