VirtualLabo / src /pages /Lab.tsx
rinogeek's picture
Update
e1633a4
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;