import { useRef, useState, useEffect } from 'react'; import { useBuilder } from '../store/builderStore'; import type { RenderMode, ViewMode, PlacedPart } from '../types'; import { downloadProject, importProjectFile, } from '../utils/serializer'; import { useAuth } from '../auth/AuthContext'; import { PaywallModal } from '../payments/PaywallModal'; import { addToast } from './Toast'; import { useSaveStatus } from '../store/saveStatusStore'; function SaveIndicator() { const status = useSaveStatus((s) => s.status); if (status === 'saved') { return ( ); } if (status === 'saving') { return ( ); } return ( ); } /** * Toolbar โ€” project actions (new/save/load/export/import), undo/redo, * tools (measure, snap), view presets, and the flow-animation toggle. */ export function Toolbar({ onOpenBom, onCheckBuild, }: { onOpenBom: () => void; onCheckBuild: () => void; }) { const { token, user, logout, refreshProfile } = useAuth(); const [showPaywall, setShowPaywall] = useState(false); const [projectsList, setProjectsList] = useState<{ name: string; savedAt: string; parts: PlacedPart[] }[]>([]); const [loadingProjects, setLoadingProjects] = useState(false); const projectName = useBuilder((s) => s.projectName); const setProjectName = useBuilder((s) => s.setProjectName); const growthStage = useBuilder((s) => s.growthStage); const setGrowthStage = useBuilder((s) => s.setGrowthStage); const viewMode = useBuilder((s) => s.viewMode); const setViewMode = useBuilder((s) => s.setViewMode); const renderMode = useBuilder((s) => s.renderMode); const setRenderMode = useBuilder((s) => s.setRenderMode); const tool = useBuilder((s) => s.tool); const setTool = useBuilder((s) => s.setTool); const showFlow = useBuilder((s) => s.showFlow); const toggleFlow = useBuilder((s) => s.toggleFlow); const simRunning = useBuilder((s) => s.simRunning); const toggleSimRunning = useBuilder((s) => s.toggleSimRunning); const snapToGrid = useBuilder((s) => s.snapToGrid); const toggleSnap = useBuilder((s) => s.toggleSnap); const undo = useBuilder((s) => s.undo); const redo = useBuilder((s) => s.redo); const canUndo = useBuilder((s) => s.past.length > 0); const canRedo = useBuilder((s) => s.future.length > 0); const autoCapOpenEnds = useBuilder((s) => s.autoCapOpenEnds); const rooms = useBuilder((s) => s.rooms); const activeRoomId = useBuilder((s) => s.activeRoomId); const addRoom = useBuilder((s) => s.addRoom); const removeRoom = useBuilder((s) => s.removeRoom); const setActiveRoomId = useBuilder((s) => s.setActiveRoomId); const fileRef = useRef(null); const [loadOpen, setLoadOpen] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const fetchProjects = async () => { if (!token) return; setLoadingProjects(true); try { const response = await fetch('/api/projects', { headers: { Authorization: `Bearer ${token}` } }); if (response.ok) { const data = await response.json(); setProjectsList(data); } } catch (err) { console.error('Failed to fetch projects', err); } finally { setLoadingProjects(false); } }; useEffect(() => { if (loadOpen) { void fetchProjects(); } }, [loadOpen]); const handleSave = async () => { if (!token) return; const s = useBuilder.getState(); const partsArray = Object.values(s.parts); try { const response = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: s.projectName, parts: partsArray }) }); const data = await response.json(); if (!response.ok) { if (response.status === 402) { setShowPaywall(true); } else { addToast(data.error || 'Failed to save project', 'error'); } return; } await refreshProfile(); addToast('Project saved successfully!', 'success'); } catch (err) { addToast('Save failed: ' + err, 'error'); } }; const handleLoad = (name: string) => { const proj = projectsList.find((p) => p.name === name); if (proj) { useBuilder.getState().loadParts(proj.parts, proj.name); } setLoadOpen(false); }; const handleDelete = async (e: React.MouseEvent, name: string) => { e.stopPropagation(); if (!token || !confirm(`Delete project "${name}" permanently?`)) return; try { const response = await fetch(`/api/projects/${encodeURIComponent(name)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); if (response.ok) { await fetchProjects(); await refreshProfile(); addToast('Project deleted successfully', 'success'); } else { const data = await response.json(); addToast(data.error || 'Failed to delete project', 'error'); } } catch (err) { addToast('Delete failed: ' + err, 'error'); } }; const handleExport = () => { const s = useBuilder.getState(); downloadProject(s.projectName, Object.values(s.parts)); }; const handleImport = async (file: File) => { try { const proj = await importProjectFile(file); useBuilder.getState().loadParts(proj.parts, proj.name); addToast('Project imported successfully', 'success'); } catch (err) { addToast(err instanceof Error ? err.message : 'Import failed', 'error'); } }; const handleNew = () => { if (confirm('Start a new empty project? Unsaved changes are kept in undo history.')) { useBuilder.getState().clearAll(); setProjectName('Untitled system'); } }; const views: { id: ViewMode; label: string; title: string }[] = [ { id: 'orbit', label: '3D', title: 'Free 3D orbit view' }, { id: 'top', label: 'Top', title: 'Top-down plan view' }, { id: 'front', label: 'Front', title: 'Front elevation view' }, { id: 'side', label: 'Side', title: 'Side elevation view' }, ]; const renderModes: { id: RenderMode; label: string; title: string }[] = [ { id: 'builder', label: 'Builder', title: 'Standard building mode' }, { id: 'xray', label: 'X-Ray', title: 'Transparent shell with internal water visibility' }, { id: 'water', label: 'Water', title: 'Water-first view with solids faded back' }, ]; return ( <>
H setProjectName(e.target.value)} className="w-44 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-sm font-semibold text-zinc-100 outline-none hover:border-zinc-800 focus:border-sky-700 focus:bg-zinc-900" />
New Save
setLoadOpen((o) => !o)} title="Load a saved project"> Load โ–พ {loadOpen && (
{loadingProjects && (
Loading...
)} {!loadingProjects && projectsList.length === 0 && (
No saved projects yet.
)} {!loadingProjects && projectsList.map((p) => (
))}
Premium Templates
{['Commercial DWC Layout', 'Basement Aeroponics Tower', 'NFT Lettuce Wall'].map((tName) => (
))}
)}
Export fileRef.current?.click()} title="Import a design JSON">Import setShowShortcuts(true)} title="Show Keyboard Shortcuts">โŒจ๏ธ Shortcuts { const f = e.target.files?.[0]; if (f) void handleImport(f); e.target.value = ''; }} /> BOM Check Build autoCapOpenEnds()} title="Add caps to every loose plumbing end"> Auto Cap โ†ถ โ†ท setTool(tool === 'measure' ? 'select' : 'measure')} title="Measure: click two points in the scene" > ๐Ÿ“ Measure setTool(tool === 'pipeRun' ? 'select' : 'pipeRun')} title="Pipe Run: click two ground points" > Pipe Run โŒ— Snap
{/* Master play / pause for the water simulation */}
Stage
๐Ÿ’ง Flow
{renderModes.map((mode) => ( ))}
{views.map((v) => ( ))}
{user && ( <>
{user.email} = user.total_slots ? 'text-amber-400' : 'text-emerald-400'}`}> {user.project_count} / {user.total_slots} Slots
{user.total_slots === 0 ? ( ) : ( user.project_count >= user.total_slots && ( ) )}
)}
{showPaywall && setShowPaywall(false)} />} {showShortcuts && (

โŒจ๏ธ Keyboard Shortcuts

Undo / Redo โŒ˜Z / โŒ˜Y
Rotate Selected R
Duplicate Selected D
Delete Selected โŒซ / Del
Run / Pause Sim Spacebar
Pan Camera View Arrow Keys
Clear Selection & Tool Esc
)}
{/* Tabbed room bar */}
{rooms.map((room) => { const isActive = room.id === activeRoomId; return (
setActiveRoomId(room.id)} > {room.name} {rooms.length > 1 && ( )}
); })}
); } function Divider() { return
; } function TBtn({ children, onClick, title, active, disabled, }: { children: React.ReactNode; onClick: () => void; title?: string; active?: boolean; disabled?: boolean; }) { return ( ); }