diff --git a/src/components/arduino/ArduinoPanel.tsx b/src/components/arduino/ArduinoPanel.tsx index 337a69d1..976a447b 100644 --- a/src/components/arduino/ArduinoPanel.tsx +++ b/src/components/arduino/ArduinoPanel.tsx @@ -97,7 +97,11 @@ export function ArduinoPanel({ files, onFileUpdate, onAddFile, currentTemplate } diff --git a/src/components/ftc/FTCPanel.tsx b/src/components/ftc/FTCPanel.tsx index 75ff7d0a..ef8a342a 100644 --- a/src/components/ftc/FTCPanel.tsx +++ b/src/components/ftc/FTCPanel.tsx @@ -204,7 +204,11 @@ export function FTCPanel({ files, onFileUpdate }: FTCPanelProps) { diff --git a/src/components/ide/IDELayout.tsx b/src/components/ide/IDELayout.tsx index f20139af..71366b16 100644 --- a/src/components/ide/IDELayout.tsx +++ b/src/components/ide/IDELayout.tsx @@ -272,6 +272,7 @@ export const IDELayout = ({ projectId, publishSlug }: IDELayoutProps) => { const [showGitImportDialog, setShowGitImportDialog] = useState(false); const [showCollabDialog, setShowCollabDialog] = useState(false); const [showPartsInventory, setShowPartsInventory] = useState(false); + const [partsInventoryPlatform, setPartsInventoryPlatform] = useState<"ftc" | "arduino" | "general" | undefined>(undefined); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isStarred, setIsStarred] = useState(false); const [isForking, setIsForking] = useState(false); @@ -1680,12 +1681,16 @@ export const IDELayout = ({ projectId, publishSlug }: IDELayoutProps) => { window.addEventListener("keydown", handleKeyDown); // Listen for parts inventory open event from ToolsPanel - const handleOpenParts = () => setShowPartsInventory(true); - window.addEventListener("open-parts-inventory", handleOpenParts); + const handleOpenParts = (event: Event) => { + const customEvent = event as CustomEvent<{ platform?: "ftc" | "arduino" | "general" }>; + setPartsInventoryPlatform(customEvent.detail?.platform); + setShowPartsInventory(true); + }; + window.addEventListener("open-parts-inventory", handleOpenParts as EventListener); return () => { window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("open-parts-inventory", handleOpenParts); + window.removeEventListener("open-parts-inventory", handleOpenParts as EventListener); }; }, [user, handleRun]); @@ -1852,6 +1857,7 @@ export const IDELayout = ({ projectId, publishSlug }: IDELayoutProps) => { open={showPartsInventory} onOpenChange={setShowPartsInventory} currentTemplate={selectedTemplate || undefined} + preferredPlatform={partsInventoryPlatform} /> diff --git a/src/components/ide/PartsInventoryDialog.tsx b/src/components/ide/PartsInventoryDialog.tsx index dd858fe2..1020d084 100644 --- a/src/components/ide/PartsInventoryDialog.tsx +++ b/src/components/ide/PartsInventoryDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Dialog, DialogContent, @@ -36,6 +36,10 @@ import { Cog, Zap, X, + Camera, + UploadCloud, + Link2, + DatabaseZap, } from "lucide-react"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/contexts/AuthContext"; @@ -66,6 +70,7 @@ interface PartsInventoryDialogProps { onOpenChange: (open: boolean) => void; currentTemplate?: string; teamId?: string | null; + preferredPlatform?: "ftc" | "arduino" | "general"; } const CATEGORIES = [ @@ -85,11 +90,37 @@ const CATEGORIES = [ { value: "other", label: "Other", icon: Package }, ]; +const VENDOR_HOSTS = [ + "www.gobilda.com", + "www.andymark.com", + "www.revrobotics.com", + "www.studica.com", + "www.vexrobotics.com", +]; + +const parseCsvRows = (csvText: string) => { + const lines = csvText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length < 2) return []; + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + return lines.slice(1).map((line) => { + const cols = line.split(",").map((c) => c.trim()); + const row: Record = {}; + headers.forEach((header, index) => { + row[header] = cols[index] || ""; + }); + return row; + }); +}; + export const PartsInventoryDialog = ({ open, onOpenChange, currentTemplate, teamId, + preferredPlatform, }: PartsInventoryDialogProps) => { const { user } = useAuth(); const { toast } = useToast(); @@ -97,7 +128,10 @@ export const PartsInventoryDialog = ({ const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [filterCategory, setFilterCategory] = useState("all"); - const [filterPlatform, setFilterPlatform] = useState("all"); + const derivedPlatform = + preferredPlatform || + (currentTemplate === "ftc" ? "ftc" : currentTemplate === "arduino" ? "arduino" : "general"); + const [activePlatform, setActivePlatform] = useState<"ftc" | "arduino" | "general">(derivedPlatform); const [tab, setTab] = useState<"inventory" | "add">("inventory"); // Add part form @@ -106,9 +140,7 @@ export const PartsInventoryDialog = ({ const [newQuantity, setNewQuantity] = useState(1); const [newLocation, setNewLocation] = useState(""); const [newLocationDetail, setNewLocationDetail] = useState(""); - const [newPlatform, setNewPlatform] = useState( - currentTemplate === "ftc" ? "ftc" : currentTemplate === "arduino" ? "arduino" : "general" - ); + const [newPlatform, setNewPlatform] = useState<"ftc" | "arduino" | "general">(derivedPlatform); const [newTags, setNewTags] = useState(""); const [aiIdentifying, setAiIdentifying] = useState(false); const [aiDetails, setAiDetails] = useState | null>(null); @@ -118,6 +150,11 @@ export const PartsInventoryDialog = ({ const [aiSpecs, setAiSpecs] = useState>({}); const [aiCompatible, setAiCompatible] = useState([]); const [saving, setSaving] = useState(false); + const [vendorUrl, setVendorUrl] = useState(""); + const [partImageBase64, setPartImageBase64] = useState(null); + const [bulkImporting, setBulkImporting] = useState(false); + const [bulkCsvSummary, setBulkCsvSummary] = useState(""); + const csvFileRef = useRef(null); // Detail view const [selectedPart, setSelectedPart] = useState(null); @@ -151,6 +188,11 @@ export const PartsInventoryDialog = ({ if (open && user) fetchParts(); }, [open, user, fetchParts]); + useEffect(() => { + setActivePlatform(derivedPlatform); + setNewPlatform(derivedPlatform); + }, [derivedPlatform, open]); + // Realtime subscription useEffect(() => { if (!open || !user) return; @@ -174,18 +216,58 @@ export const PartsInventoryDialog = ({ p.manufacturer?.toLowerCase().includes(searchQuery.toLowerCase()) || p.part_number?.toLowerCase().includes(searchQuery.toLowerCase()); const matchesCategory = filterCategory === "all" || p.category === filterCategory; - const matchesPlatform = filterPlatform === "all" || p.platform === filterPlatform; + const matchesPlatform = + p.platform === activePlatform || (activePlatform !== "general" && p.platform === "general"); return matchesSearch && matchesCategory && matchesPlatform; }); - }, [parts, searchQuery, filterCategory, filterPlatform]); + }, [parts, searchQuery, filterCategory, activePlatform]); const identifyWithAI = async () => { if (!newName.trim()) return; setAiIdentifying(true); setAiDetails(null); try { + const existing = parts.find( + (p) => + p.name.trim().toLowerCase() === newName.trim().toLowerCase() && + (p.platform === newPlatform || p.platform === "general"), + ); + + if (existing) { + setAiDetails(existing.ai_details || {}); + setAiDescription(existing.description || ""); + setAiManufacturer(existing.manufacturer || ""); + setAiPartNumber(existing.part_number || ""); + setAiSpecs(existing.specifications || {}); + setAiCompatible(existing.compatible_with || []); + setNewCategory(existing.category || "other"); + toast({ + title: "Loaded from parts library", + description: "This part already exists, so AI did not regenerate it.", + }); + return; + } + + const aiCacheKey = `parts-ai-library-${newPlatform}`; + const cached = localStorage.getItem(aiCacheKey); + if (cached) { + const cachedParts: Record = JSON.parse(cached); + const cachedPart = cachedParts[newName.trim().toLowerCase()]; + if (cachedPart) { + setAiDetails(cachedPart); + setAiDescription(cachedPart.description || ""); + setAiManufacturer(cachedPart.manufacturer || ""); + setAiPartNumber(cachedPart.partNumber || ""); + setAiSpecs(cachedPart.specifications || {}); + setAiCompatible(cachedPart.compatibleWith || []); + if (cachedPart.category) setNewCategory(cachedPart.category); + toast({ title: "Loaded from AI library cache", description: "Reused previous AI identification." }); + return; + } + } + const { data, error } = await supabase.functions.invoke("identify-part", { - body: { partName: newName, platform: newPlatform }, + body: { partName: newName, platform: newPlatform, vendorUrl, imageBase64: partImageBase64 }, }); if (error) throw error; if (data.error) throw new Error(data.error); @@ -197,6 +279,10 @@ export const PartsInventoryDialog = ({ setAiSpecs(data.specifications || {}); setAiCompatible(data.compatibleWith || []); if (data.category) setNewCategory(data.category); + const cachedAfter = localStorage.getItem(aiCacheKey); + const cacheData = cachedAfter ? JSON.parse(cachedAfter) : {}; + cacheData[newName.trim().toLowerCase()] = data; + localStorage.setItem(aiCacheKey, JSON.stringify(cacheData)); toast({ title: "Part identified!", description: "AI filled in details for you." }); } catch (e: any) { toast({ title: "AI identification failed", description: e.message, variant: "destructive" }); @@ -244,6 +330,82 @@ export const PartsInventoryDialog = ({ } }; + const handleImageUpload = async (file?: File | null) => { + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const value = typeof reader.result === "string" ? reader.result : ""; + setPartImageBase64(value); + }; + reader.readAsDataURL(file); + }; + + const handleBulkImportCsv = async (csvText: string) => { + if (!user) return; + const rows = parseCsvRows(csvText); + if (!rows.length) { + toast({ title: "No rows found", description: "CSV needs a header and at least one row." }); + return; + } + + setBulkImporting(true); + let imported = 0; + let aiEnhanced = 0; + try { + for (const row of rows) { + const name = row.name || row.part_name || row.item || ""; + if (!name) continue; + let description = row.description || null; + let category = row.category || "other"; + let manufacturer = row.manufacturer || null; + let partNumber = row.part_number || row.partnumber || null; + const quantity = Number(row.quantity || "1") || 1; + let specifications: Record = {}; + + if (!description || !manufacturer || category === "other") { + const ai = await supabase.functions.invoke("identify-part", { + body: { partName: name, platform: activePlatform }, + }); + if (!ai.error && ai.data && !ai.data.error) { + description = description || ai.data.description || null; + category = category === "other" ? ai.data.category || "other" : category; + manufacturer = manufacturer || ai.data.manufacturer || null; + partNumber = partNumber || ai.data.partNumber || null; + specifications = ai.data.specifications || {}; + aiEnhanced += 1; + } + } + + const { error } = await supabase.from("parts_inventory").insert({ + user_id: user.id, + team_id: teamId || null, + name, + description, + category, + quantity, + location: row.location || null, + location_detail: row.location_detail || null, + part_number: partNumber, + manufacturer, + specifications, + tags: (row.tags || "") + .split("|") + .map((t) => t.trim()) + .filter(Boolean), + compatible_with: [activePlatform, "general"], + platform: activePlatform, + ai_details: {}, + } as any); + if (!error) imported += 1; + } + setBulkCsvSummary(`Imported ${imported} parts (${aiEnhanced} enhanced with AI).`); + toast({ title: "Bulk import complete", description: `Imported ${imported} part(s).` }); + fetchParts(); + } finally { + setBulkImporting(false); + } + }; + const deletePart = async (id: string) => { try { const { error } = await supabase.from("parts_inventory").delete().eq("id", id); @@ -269,6 +431,8 @@ export const PartsInventoryDialog = ({ setAiPartNumber(""); setAiSpecs({}); setAiCompatible([]); + setVendorUrl(""); + setPartImageBase64(null); }; const getCategoryIcon = (cat: string) => { @@ -283,13 +447,30 @@ export const PartsInventoryDialog = ({ - Parts Inventory + {activePlatform.toUpperCase()} Parts Library - Manage your robotics & electronics parts. AI identifies details automatically. + Dedicated {activePlatform.toUpperCase()} inventory with AI-assisted detection, material hints, and vendor lookups. +
+ {(["ftc", "arduino", "general"] as const).map((platform) => ( + + ))} +
+ setTab(v as any)} className="flex-1 min-h-0 flex flex-col"> @@ -323,17 +504,6 @@ export const PartsInventoryDialog = ({ ))} - {selectedPart ? ( @@ -570,20 +740,60 @@ export const PartsInventoryDialog = ({
- - + +
+ {newPlatform} +
+
+ + setVendorUrl(e.target.value)} + placeholder={`https://${VENDOR_HOSTS[0]}/...`} + /> +

+ Supports goBILDA, AndyMark, REV, Studica, VEX, and similar vendor product pages. +

+
+ +
+ +
+ handleImageUpload(e.target.files?.[0])} /> + +
+ { + const file = e.target.files?.[0]; + if (!file) return; + const text = await file.text(); + await handleBulkImportCsv(text); + }} + /> + handleImageUpload(e.target.files?.[0])} /> + {partImageBase64 && ( + Part preview + )} + {bulkCsvSummary && ( +

+ {bulkCsvSummary} +

+ )} +
+
diff --git a/supabase/functions/identify-part/index.ts b/supabase/functions/identify-part/index.ts index 31cc79b9..69e084c5 100644 --- a/supabase/functions/identify-part/index.ts +++ b/supabase/functions/identify-part/index.ts @@ -10,15 +10,31 @@ serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); try { - const { partName, platform } = await req.json(); + const { partName, platform, vendorUrl, imageBase64 } = await req.json(); const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured"); + let vendorContext = ""; + if (typeof vendorUrl === "string" && vendorUrl.trim()) { + try { + const vendorResp = await fetch(vendorUrl.trim()); + if (vendorResp.ok) { + const html = await vendorResp.text(); + const titleMatch = html.match(/(.*?)<\/title>/i); + const ogImageMatch = html.match(/property=["']og:image["']\s+content=["']([^"']+)["']/i); + vendorContext = `Vendor URL: ${vendorUrl}\nVendor title: ${titleMatch?.[1] ?? "unknown"}\nVendor image: ${ogImageMatch?.[1] ?? "unknown"}`; + } + } catch (error) { + console.warn("vendor context fetch failed", error); + } + } + const systemPrompt = `You are an electronics and robotics parts expert. Given a part name, provide detailed identification information. Return a JSON object with these fields: - description: A clear 1-2 sentence description of what this part does - category: One of: motor, servo, sensor, controller, structural, electrical, connector, wheel, gear, bearing, fastener, battery, cable, other - manufacturer: The likely manufacturer (or "Generic" if unknown) - partNumber: The common part number if known (or null) +- material: likely primary material(s) as a short string (e.g. "6061 aluminum", "ABS plastic") - specifications: An object with relevant specs (voltage, current, dimensions, weight, etc.) - compatibleWith: Array of platforms this part works with (e.g. ["ftc", "arduino", "general"]) - commonUses: Array of 3-5 common use cases @@ -26,6 +42,8 @@ serve(async (req) => { - alternativeParts: Array of 1-3 alternative/equivalent parts Platform context: ${platform || "general"} +${vendorContext ? `\n${vendorContext}` : ""} +Image provided: ${imageBase64 ? "yes" : "no"} (if yes, infer probable material/part family from visual cues) IMPORTANT: Return ONLY valid JSON, no markdown fences.`;