Initial project import

This commit is contained in:
drjones
2026-06-13 17:36:44 -07:00
commit ad2a18cc8d
18471 changed files with 4497570 additions and 0 deletions

555
src/components/Toolbar.tsx Normal file
View File

@@ -0,0 +1,555 @@
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>
)}
</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>
);
}