Files
hydro-builder-app/src/components/Toolbar.tsx

587 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<span
title="All changes saved to browser"
className="h-2 w-2 rounded-full bg-emerald-500 transition-colors duration-300 shrink-0"
/>
);
}
if (status === 'saving') {
return (
<span
title="Saving changes..."
className="h-2 w-2 rounded-full bg-amber-500 animate-pulse shrink-0"
/>
);
}
return (
<span
title="Unsaved changes"
className="h-2 w-2 rounded-full bg-amber-500 shrink-0"
/>
);
}
/**
* 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<HTMLInputElement>(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 (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-zinc-800 bg-zinc-950 px-3">
<div className="flex items-center gap-2 pr-2">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-sky-500 to-blue-700 text-sm font-black text-white">
H
</span>
<input
value={projectName}
onChange={(e) => 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"
/>
<SaveIndicator />
</div>
<Divider />
<TBtn onClick={handleNew} title="New empty project">New</TBtn>
<TBtn onClick={handleSave} title="Save to cloud storage">Save</TBtn>
<div className="relative">
<TBtn onClick={() => setLoadOpen((o) => !o)} title="Load a saved project">
Load
</TBtn>
{loadOpen && (
<div className="absolute top-9 left-0 z-50 max-h-72 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 p-1 shadow-2xl">
{loadingProjects && (
<div className="px-3 py-2 text-xs text-zinc-500">Loading...</div>
)}
{!loadingProjects && projectsList.length === 0 && (
<div className="px-3 py-2 text-xs text-zinc-500">No saved projects yet.</div>
)}
{!loadingProjects && projectsList.map((p) => (
<div
key={p.name}
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-zinc-800 group"
>
<button
onClick={() => handleLoad(p.name)}
className="flex-1 text-left text-xs text-zinc-200 outline-none"
>
{p.name}
<span className="block text-[10px] text-zinc-500">
{new Date(p.savedAt).toLocaleString()}
</span>
</button>
<button
onClick={(e) => handleDelete(e, p.name)}
className="hidden group-hover:block text-[10px] text-red-400 hover:text-red-300 px-2 py-0.5 rounded bg-zinc-950/40"
title="Delete project"
>
</button>
</div>
))}
<div className="mt-2 border-t border-zinc-700/50 pt-2">
<div className="px-3 pb-1 text-[10px] font-bold tracking-wider text-amber-500 uppercase">
Premium Templates
</div>
{['Commercial DWC Layout', 'Basement Aeroponics Tower', 'NFT Lettuce Wall'].map((tName) => (
<div key={tName} className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-amber-950/30 group">
<button
onClick={() => {
if (!user || user.purchased_slots === 0) {
setShowPaywall(true);
setLoadOpen(false);
} else {
// Normally we would load the specific template JSON here.
// For the demo, we just reuse demoParts or clear the board.
addToast(`Loaded template: ${tName}`, 'success');
useBuilder.getState().clearAll();
setProjectName(tName);
setLoadOpen(false);
}
}}
className="flex-1 text-left text-xs text-amber-200/80 outline-none"
>
{tName} 🔒
<span className="block text-[10px] text-zinc-500">
Pro Template
</span>
</button>
</div>
))}
</div>
</div>
)}
</div>
<TBtn onClick={handleExport} title="Download design as JSON">Export</TBtn>
<TBtn onClick={() => fileRef.current?.click()} title="Import a design JSON">Import</TBtn>
<TBtn onClick={() => setShowShortcuts(true)} title="Show Keyboard Shortcuts"> Shortcuts</TBtn>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleImport(f);
e.target.value = '';
}}
/>
<Divider />
<TBtn onClick={onOpenBom} title="Open bill of materials">BOM</TBtn>
<TBtn onClick={onCheckBuild} title="Run design validation">Check Build</TBtn>
<TBtn onClick={() => autoCapOpenEnds()} title="Add caps to every loose plumbing end">
Auto Cap
</TBtn>
<Divider />
<TBtn onClick={undo} disabled={!canUndo} title="Undo (⌘Z)"></TBtn>
<TBtn onClick={redo} disabled={!canRedo} title="Redo (⇧⌘Z)"></TBtn>
<Divider />
<TBtn
active={tool === 'measure'}
onClick={() => setTool(tool === 'measure' ? 'select' : 'measure')}
title="Measure: click two points in the scene"
>
📏 Measure
</TBtn>
<TBtn
active={tool === 'pipeRun'}
onClick={() => setTool(tool === 'pipeRun' ? 'select' : 'pipeRun')}
title="Pipe Run: click two ground points"
>
Pipe Run
</TBtn>
<TBtn active={snapToGrid} onClick={toggleSnap} title="Snap to grid (0.25 ft)">
Snap
</TBtn>
<div className="ml-auto flex items-center gap-2">
{/* Master play / pause for the water simulation */}
<button
onClick={toggleSimRunning}
title={simRunning ? 'Pause water simulation (Space)' : 'Run water simulation (Space)'}
className={`grid h-8 w-8 shrink-0 place-items-center rounded-full text-white shadow-lg transition-all hover:scale-110 active:scale-95 ${
simRunning
? 'bg-emerald-500 shadow-emerald-500/40 hover:bg-emerald-400'
: 'bg-sky-600 shadow-sky-600/40 hover:bg-sky-500'
}`}
>
{simRunning ? (
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor">
<rect x="3" y="2.5" width="3.4" height="11" rx="1" />
<rect x="9.6" y="2.5" width="3.4" height="11" rx="1" />
</svg>
) : (
<svg viewBox="0 0 16 16" className="ml-0.5 h-3.5 w-3.5" fill="currentColor">
<path d="M4 2.8a1 1 0 0 1 1.52-.86l8.2 5.2a1 1 0 0 1 0 1.7l-8.2 5.2A1 1 0 0 1 4 13.2V2.8Z" />
</svg>
)}
</button>
<div className="flex items-center gap-1.5 bg-zinc-900 border border-zinc-800 rounded-lg px-2.5 py-1">
<span className="text-[10px] uppercase font-bold text-zinc-400">Stage</span>
<select
value={growthStage}
onChange={(e) => setGrowthStage(e.target.value as any)}
className="bg-transparent text-xs text-zinc-200 outline-none border-none cursor-pointer pr-1"
>
<option value="seedling" className="bg-zinc-900 text-zinc-200">Seedling</option>
<option value="vegetative" className="bg-zinc-900 text-zinc-200">Vegetative</option>
<option value="flowering" className="bg-zinc-900 text-zinc-200">Flowering</option>
</select>
</div>
<TBtn active={showFlow} onClick={toggleFlow} title="Toggle water flow animation">
💧 Flow
</TBtn>
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
{renderModes.map((mode) => (
<button
key={mode.id}
onClick={() => setRenderMode(mode.id)}
title={mode.title}
className={`px-3 py-1.5 text-xs font-medium transition ${
renderMode === mode.id
? 'bg-cyan-600 text-white'
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
}`}
>
{mode.label}
</button>
))}
</div>
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
{views.map((v) => (
<button
key={v.id}
onClick={() => setViewMode(v.id)}
title={v.title}
className={`px-3 py-1.5 text-xs font-medium transition ${
viewMode === v.id
? 'bg-sky-600 text-white'
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
}`}
>
{v.label}
</button>
))}
</div>
{user && (
<>
<Divider />
<div className="flex items-center gap-2 px-1">
<div className="flex flex-col items-end">
<span className="text-[10px] font-semibold text-zinc-300 max-w-24 truncate">
{user.email}
</span>
<span className={`text-[9px] font-bold uppercase ${user.total_slots === 0 ? 'text-amber-400' : user.project_count >= user.total_slots ? 'text-amber-400' : 'text-emerald-400'}`}>
{user.project_count} / {user.total_slots} Slots
</span>
</div>
{user.total_slots === 0 ? (
<button
onClick={() => setShowPaywall(true)}
className="rounded bg-gradient-to-r from-sky-500/20 to-blue-500/20 hover:from-sky-500/30 hover:to-blue-500/30 text-sky-300 border border-sky-700 px-2 py-1 text-[10px] font-bold transition cursor-pointer flex items-center gap-1 shadow-lg"
>
💎 Upgrade
</button>
) : (
user.project_count >= user.total_slots && (
<button
onClick={() => setShowPaywall(true)}
className="rounded bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-600/30 px-2 py-1 text-[10px] font-semibold transition cursor-pointer"
>
+ Add Slot
</button>
)
)}
<button
onClick={logout}
className="rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-[10px] font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition cursor-pointer"
>
Logout
</button>
</div>
</>
)}
</div>
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
{showShortcuts && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-2xl text-left">
<h3 className="text-sm font-bold text-zinc-100 mb-4 flex items-center gap-1.5 border-b border-zinc-800 pb-2">
Keyboard Shortcuts
</h3>
<div className="space-y-2.5 text-xs text-zinc-400">
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Undo / Redo</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Z / Y</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Rotate Selected</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">R</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Duplicate Selected</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">D</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Delete Selected</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300"> / Del</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Run / Pause Sim</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Spacebar</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Pan Camera View</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Arrow Keys</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Clear Selection & Tool</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Esc</span>
</div>
</div>
<button
onClick={() => setShowShortcuts(false)}
className="mt-6 w-full rounded bg-zinc-800 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-700 transition cursor-pointer"
>
Close
</button>
</div>
</div>
)}
</header>
{/* Tabbed room bar */}
<div className="flex h-10 shrink-0 items-center justify-between border-b border-zinc-850 bg-zinc-900/60 px-3">
<div className="flex items-center gap-1.5 overflow-x-auto">
{rooms.map((room) => {
const isActive = room.id === activeRoomId;
return (
<div
key={room.id}
className={`group flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold transition cursor-pointer select-none ${
isActive
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
: 'text-zinc-400 hover:text-zinc-200 border border-transparent'
}`}
onClick={() => setActiveRoomId(room.id)}
>
<span>{room.name}</span>
{rooms.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete room "${room.name}" and all its parts permanently?`)) {
removeRoom(room.id);
}
}}
className="opacity-0 group-hover:opacity-100 hover:text-red-400 text-[10px] ml-1 transition"
>
</button>
)}
</div>
);
})}
</div>
<button
onClick={() => {
const name = prompt('Enter room name:', `Room ${rooms.length + 1}`);
if (name) {
addRoom(name.trim());
}
}}
className="rounded border border-zinc-700 bg-zinc-850 hover:bg-zinc-800 text-zinc-300 px-2.5 py-1 text-[11px] font-semibold transition flex items-center gap-1 cursor-pointer"
>
+ Add Room
</button>
</div>
</>
);
}
function Divider() {
return <div className="mx-1 h-6 w-px bg-zinc-800" />;
}
function TBtn({
children,
onClick,
title,
active,
disabled,
}: {
children: React.ReactNode;
onClick: () => void;
title?: string;
active?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
title={title}
disabled={disabled}
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition disabled:cursor-default disabled:opacity-30
${
active
? 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700'
: 'text-zinc-300 hover:bg-zinc-800'
}`}
>
{children}
</button>
);
}