import { useState, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Beaker, Home, AlertTriangle } from "lucide-react"; import { Link } from "react-router-dom"; import FabricLabCanvas from "@/components/lab/FabricLabCanvas"; import Inventory from "@/components/lab/Inventory"; import ResultsPanel from "@/components/lab/ResultsPanel"; import WorkspaceManager, { Workspace } from "@/components/lab/WorkspaceManager"; import { TimelapseRecorder } from "@/components/lab/TimelapseRecorder"; import { ChemicalCalculator } from "@/components/lab/ChemicalCalculator"; import { useEffect } from "react"; import { experimentTemplates } from "@/data/experimentTemplates"; import { substances } from "@/data/substances"; import { toast } from "sonner"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { generateAiMixObservation } from "@/services/aiMix"; const STORAGE_KEY = 'lab-workspaces'; const Lab = () => { const [selectedItem, setSelectedItem] = useState(null); const [results, setResults] = useState<{ observation: string; equation: string; application: string }[]>([]); const [safetyWarning, setSafetyWarning] = useState(null); const [temperatureC, setTemperatureC] = useState(20); const canvasRef = useRef<{ getSnapshot: () => string | null; loadSnapshot: (data: string) => void } | null>(null); const pendingMixKeyRef = useRef(""); const lastSuccessMixKeyRef = useRef(""); // Load workspaces from localStorage or use default const loadWorkspaces = (): Workspace[] => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (Array.isArray(parsed) && parsed.length > 0) { toast.success("Expériences restaurées depuis la dernière session"); return parsed; } } } catch (error) { console.error("Erreur lors du chargement:", error); } return [ { id: "1", name: "Expérience 1", canvasData: null, isFavorite: false, history: [], historyIndex: -1 }, ]; }; // Workspace management const [workspaces, setWorkspaces] = useState(loadWorkspaces); const [activeWorkspaceId, setActiveWorkspaceId] = useState(() => { const saved = loadWorkspaces(); return saved[0]?.id || "1"; }); const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId); const canUndo = (activeWorkspace?.historyIndex ?? -1) > 0; const canRedo = (activeWorkspace?.historyIndex ?? -1) < (activeWorkspace?.history?.length ?? 0) - 1; // Auto-save workspaces to localStorage useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspaces)); } catch (error) { console.error("Erreur lors de la sauvegarde:", error); } }, [workspaces]); const checkSafety = (substanceIds: string[]) => { const picked = substanceIds .map((id) => substances.find((s) => s.id === id)) .filter(Boolean); if (picked.length < 2) return; const high = picked.filter((s: any) => s.hazard === "high"); if (high.length >= 2) { setSafetyWarning( `⚠️ DANGER: Plusieurs substances très dangereuses sont présentes (${high .map((s: any) => s.name) .join(" + ")}). Portez un équipement de protection complet !`, ); setTimeout(() => setSafetyWarning(null), 8000); return; } if (high.length === 1) { setSafetyWarning(`⚠️ ATTENTION: Substance dangereuse (${high[0].name}). Manipulez avec précaution.`); setTimeout(() => setSafetyWarning(null), 6000); } }; const handleMix = async (substanceIds: string[]) => { const unique = Array.from(new Set(substanceIds)).filter(Boolean); if (unique.length < 2) return; // Safety banner (instant feedback), then ask the IA. checkSafety(unique); const key = `${[...unique].sort().join("+")}@${temperatureC}`; if (pendingMixKeyRef.current === key || lastSuccessMixKeyRef.current === key) return; pendingMixKeyRef.current = key; try { const picked = unique .map((id) => substances.find((s) => s.id === id)) .filter(Boolean) .map((s: any) => ({ id: s.id, name: s.name, formula: s.formula, state: s.state, hazard: s.hazard, description: s.description, })); if (picked.length < 2) throw new Error("Substances invalides pour l'IA"); const text = await generateAiMixObservation({ substances: picked, substanceIds: unique, temperatureC, }); setResults((prev) => [...prev, text]); lastSuccessMixKeyRef.current = key; } catch (error) { console.error("AI mix error:", error); toast.error(error instanceof Error ? error.message : "Erreur lors de la génération IA"); } finally { if (pendingMixKeyRef.current === key) pendingMixKeyRef.current = ""; } }; const handleWorkspaceChange = (id: string) => { setActiveWorkspaceId(id); }; const handleWorkspaceAdd = () => { const newId = String(Date.now()); const newWorkspace: Workspace = { id: newId, name: `Expérience ${workspaces.length + 1}`, canvasData: null, isFavorite: false, history: [], historyIndex: -1, }; setWorkspaces((prev) => [...prev, newWorkspace]); setActiveWorkspaceId(newId); }; const handleWorkspaceDelete = (id: string) => { if (workspaces.length === 1) return; setWorkspaces((prev) => prev.filter((w) => w.id !== id)); if (activeWorkspaceId === id) { const remainingWorkspaces = workspaces.filter((w) => w.id !== id); setActiveWorkspaceId(remainingWorkspaces[0].id); } }; const handleWorkspaceRename = (id: string, name: string) => { setWorkspaces((prev) => prev.map((w) => (w.id === id ? { ...w, name } : w)) ); }; const handleWorkspaceFavorite = (id: string) => { setWorkspaces((prev) => prev.map((w) => (w.id === id ? { ...w, isFavorite: !w.isFavorite } : w)) ); }; const handleCanvasChange = (data: string) => { setWorkspaces((prev) => prev.map((w) => { if (w.id === activeWorkspaceId) { const currentHistory = w.history || []; const currentIndex = w.historyIndex ?? -1; // Remove any "future" history if we're not at the end const newHistory = currentHistory.slice(0, currentIndex + 1); // Add new state to history (limit to 50 states) const updatedHistory = [...newHistory, data].slice(-50); return { ...w, canvasData: data, history: updatedHistory, historyIndex: updatedHistory.length - 1, }; } return w; }) ); }; const handleUndo = () => { if (!canUndo || !activeWorkspace) return; const newIndex = (activeWorkspace.historyIndex ?? 0) - 1; const previousState = activeWorkspace.history?.[newIndex]; if (previousState !== undefined) { setWorkspaces((prev) => prev.map((w) => w.id === activeWorkspaceId ? { ...w, canvasData: previousState, historyIndex: newIndex } : w ) ); } }; const handleRedo = () => { if (!activeWorkspace || !canRedo) return; const newIndex = activeWorkspace.historyIndex! + 1; const canvasData = activeWorkspace.history![newIndex]; setWorkspaces((prev) => prev.map((w) => w.id === activeWorkspaceId ? { ...w, canvasData, historyIndex: newIndex } : w ) ); }; // Import workspace from JSON const handleImportWorkspace = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const imported = JSON.parse(event.target?.result as string) as Workspace; // Validate structure if (!imported.id || !imported.name) { toast.error("Fichier JSON invalide"); return; } // Generate new ID to avoid conflicts const newWorkspace: Workspace = { ...imported, id: String(Date.now()), name: `${imported.name} (importé)` }; setWorkspaces((prev) => [...prev, newWorkspace]); setActiveWorkspaceId(newWorkspace.id); toast.success(`Plan de travail "${newWorkspace.name}" importé avec succès`); } catch (error) { toast.error("Erreur lors de l'importation du fichier JSON"); console.error(error); } }; reader.readAsText(file); }; input.click(); }; // Export workspace to JSON const handleExportWorkspace = () => { if (!activeWorkspace) return; const dataStr = JSON.stringify(activeWorkspace, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `${activeWorkspace.name.replace(/\s+/g, '_')}_${Date.now()}.json`; link.click(); URL.revokeObjectURL(url); toast.success("Plan de travail exporté"); }; // Load experiment template const handleLoadTemplate = (templateId: string) => { const template = experimentTemplates.find(t => t.id === templateId); if (!template) return; const newId = String(Date.now()); const newWorkspace: Workspace = { id: newId, name: template.name, canvasData: null, isFavorite: false, history: [], historyIndex: -1, }; setWorkspaces((prev) => [...prev, newWorkspace]); setActiveWorkspaceId(newId); // Show instructions toast.success(`Modèle "${template.name}" chargé`, { description: template.instructions[0] }); }; // Keyboard shortcuts for undo/redo useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedo(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [canUndo, canRedo, activeWorkspaceId, workspaces]); return (
{/* Header */}

Laboratoire Virtuel

Simulez des expériences chimiques en toute sécurité

{/* Safety Warning */} {safetyWarning && ( Avertissement de sécurité {safetyWarning} )} {/* Workspace Manager */}
{/* Inventory Sidebar */}
{/* Main Lab Area */}

{activeWorkspace?.name}

Ajoutez des substances et déplacez-les librement

{/* Results Panel */}
setResults([])} /> canvasRef.current?.getSnapshot() || null} onLoadSnapshot={(data) => canvasRef.current?.loadSnapshot(data)} />
); }; export default Lab;