Spaces:
Sleeping
Sleeping
| 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<string | null>(null); | |
| const [results, setResults] = useState<{ observation: string; equation: string; application: string }[]>([]); | |
| const [safetyWarning, setSafetyWarning] = useState<string | null>(null); | |
| const [temperatureC, setTemperatureC] = useState<number>(20); | |
| const canvasRef = useRef<{ getSnapshot: () => string | null; loadSnapshot: (data: string) => void } | null>(null); | |
| const pendingMixKeyRef = useRef<string>(""); | |
| const lastSuccessMixKeyRef = useRef<string>(""); | |
| // 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<Workspace[]>(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 ( | |
| <div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5"> | |
| <div className="container mx-auto p-4 space-y-4"> | |
| {/* Header */} | |
| <Card className="p-4 bg-card/50 backdrop-blur-sm border-border/50"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <Beaker className="w-8 h-8 text-primary" /> | |
| <div> | |
| <h1 className="text-2xl font-bold text-foreground">Laboratoire Virtuel</h1> | |
| <p className="text-sm text-muted-foreground"> | |
| Simulez des expériences chimiques en toute sécurité | |
| </p> | |
| </div> | |
| </div> | |
| <Link to="/"> | |
| <Button variant="outline" size="sm"> | |
| <Home className="w-4 h-4 mr-2" /> | |
| Accueil | |
| </Button> | |
| </Link> | |
| </div> | |
| </Card> | |
| {/* Safety Warning */} | |
| {safetyWarning && ( | |
| <Alert variant="destructive" className="animate-fade-in"> | |
| <AlertTriangle className="h-4 w-4" /> | |
| <AlertTitle>Avertissement de sécurité</AlertTitle> | |
| <AlertDescription>{safetyWarning}</AlertDescription> | |
| </Alert> | |
| )} | |
| {/* Workspace Manager */} | |
| <WorkspaceManager | |
| workspaces={workspaces} | |
| activeWorkspaceId={activeWorkspaceId} | |
| onWorkspaceChange={handleWorkspaceChange} | |
| onWorkspaceAdd={handleWorkspaceAdd} | |
| onWorkspaceDelete={handleWorkspaceDelete} | |
| onWorkspaceRename={handleWorkspaceRename} | |
| onWorkspaceFavorite={handleWorkspaceFavorite} | |
| onUndo={handleUndo} | |
| onRedo={handleRedo} | |
| canUndo={canUndo} | |
| canRedo={canRedo} | |
| onImport={handleImportWorkspace} | |
| onExport={handleExportWorkspace} | |
| onLoadTemplate={handleLoadTemplate} | |
| templates={experimentTemplates} | |
| /> | |
| <div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> | |
| {/* Inventory Sidebar */} | |
| <div className="lg:col-span-1 space-y-4"> | |
| <Inventory | |
| selectedItem={selectedItem} | |
| onSelectItem={setSelectedItem} | |
| /> | |
| <ChemicalCalculator /> | |
| </div> | |
| {/* Main Lab Area */} | |
| <div className="lg:col-span-2"> | |
| <Card className="p-6 shadow-elegant"> | |
| <div className="mb-4"> | |
| <h2 className="text-xl font-semibold mb-2"> | |
| {activeWorkspace?.name} | |
| </h2> | |
| <p className="text-sm text-muted-foreground"> | |
| Ajoutez des substances et déplacez-les librement | |
| </p> | |
| </div> | |
| <FabricLabCanvas | |
| ref={canvasRef} | |
| selectedItem={selectedItem} | |
| onMix={handleMix} | |
| canvasData={activeWorkspace?.canvasData || null} | |
| onCanvasChange={handleCanvasChange} | |
| temperatureC={temperatureC} | |
| onTemperatureChange={setTemperatureC} | |
| /> | |
| </Card> | |
| </div> | |
| {/* Results Panel */} | |
| <div className="lg:col-span-1 space-y-4"> | |
| <ResultsPanel results={results} onClear={() => setResults([])} /> | |
| <TimelapseRecorder | |
| onRequestSnapshot={() => canvasRef.current?.getSnapshot() || null} | |
| onLoadSnapshot={(data) => canvasRef.current?.loadSnapshot(data)} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default Lab; | |