496 lines
20 KiB
TypeScript
496 lines
20 KiB
TypeScript
import { useEffect, useState, useMemo } from 'react';
|
|
import { SceneCanvas, clientToGround } from './components/SceneCanvas';
|
|
import { PartLibrary } from './components/PartLibrary';
|
|
import { PropertiesPanel } from './components/PropertiesPanel';
|
|
import { Toolbar } from './components/Toolbar';
|
|
import { StatusPanel } from './components/StatusPanel';
|
|
import { ColorPopover } from './components/ColorPopover';
|
|
import { BomPanel } from './bom';
|
|
import { DesignCheckPanel, validateSystem } from './validation';
|
|
import { getHotbarPartForKey } from './parts/hotbar';
|
|
import { useSimulation } from './simulation/flowSimulator';
|
|
import { snapValue, useBuilder } from './store/builderStore';
|
|
import { autosave, loadAutosave } from './utils/serializer';
|
|
import { demoParts } from './utils/demoProject';
|
|
import type { PartType } from './types';
|
|
import type { Finding } from './validation';
|
|
import { AuthProvider, useAuth } from './auth/AuthContext';
|
|
import { useCollab } from './hooks/useCollab';
|
|
import { LoginModal } from './auth/LoginModal';
|
|
import { ToastContainer, addToast } from './components/Toast';
|
|
import { useSaveStatus } from './store/saveStatusStore';
|
|
import { TestimonialScroller } from './components/TestimonialScroller';
|
|
import { ScheduleDashboard } from './components/ScheduleDashboard';
|
|
|
|
/** Restore the autosaved design, or seed the demo system on first visit. */
|
|
/** Restore the autosaved design, or seed the demo system on first visit. */
|
|
function useBootstrap(email?: string, loading?: boolean) {
|
|
const { user } = useAuth();
|
|
const hasUpgraded = user && user.purchased_slots > 0;
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
const saved = hasUpgraded ? loadAutosave(email) : null;
|
|
if (saved && saved.parts.length > 0) {
|
|
useBuilder.getState().loadParts(saved.parts, saved.name);
|
|
} else {
|
|
useBuilder.getState().loadParts(demoParts(), 'Demo: pump loop');
|
|
}
|
|
}, [email, loading, hasUpgraded]);
|
|
}
|
|
|
|
/** Debounced autosave whenever parts change. */
|
|
function useAutosave(email?: string, loading?: boolean) {
|
|
const { user } = useAuth();
|
|
const hasUpgraded = user && user.purchased_slots > 0;
|
|
const setStatus = useSaveStatus((s) => s.setStatus);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!hasUpgraded) {
|
|
setStatus('unsaved');
|
|
return;
|
|
}
|
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let hasUnsavedChanges = false;
|
|
const unsub = useBuilder.subscribe((s, prev) => {
|
|
if (s.parts !== prev.parts) {
|
|
hasUnsavedChanges = true;
|
|
setStatus('unsaved');
|
|
}
|
|
const isDragging = s.draggingId !== null || s.gizmoDragging;
|
|
const wasDragging = prev.draggingId !== null || prev.gizmoDragging;
|
|
const dragEnded = wasDragging && !isDragging;
|
|
const partsChangedWhileNotDragging = (s.parts !== prev.parts) && !isDragging;
|
|
|
|
if (hasUnsavedChanges && (dragEnded || partsChangedWhileNotDragging)) {
|
|
clearTimeout(timer);
|
|
setStatus('saving');
|
|
timer = setTimeout(() => {
|
|
autosave(useBuilder.getState().projectName, Object.values(useBuilder.getState().parts), email);
|
|
hasUnsavedChanges = false;
|
|
setStatus('saved');
|
|
}, 600);
|
|
} else if (isDragging) {
|
|
clearTimeout(timer);
|
|
timer = undefined;
|
|
}
|
|
});
|
|
return () => {
|
|
unsub();
|
|
clearTimeout(timer);
|
|
};
|
|
}, [email, loading, hasUpgraded, setStatus]);
|
|
}
|
|
|
|
/** Global keyboard shortcuts. */
|
|
function useShortcuts() {
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
|
const s = useBuilder.getState();
|
|
const mod = e.metaKey || e.ctrlKey;
|
|
|
|
if (mod && e.key.toLowerCase() === 'z') {
|
|
e.preventDefault();
|
|
e.shiftKey ? s.redo() : s.undo();
|
|
return;
|
|
}
|
|
if (mod && e.key.toLowerCase() === 'y') {
|
|
e.preventDefault();
|
|
s.redo();
|
|
return;
|
|
}
|
|
if (mod && e.key.toLowerCase() === 'a') {
|
|
e.preventDefault();
|
|
s.selectAll();
|
|
return;
|
|
}
|
|
if (mod && e.key.toLowerCase() === 'c') {
|
|
const selectedParts = s.selectedIds.map(id => s.parts[id]).filter(Boolean);
|
|
if (selectedParts.length > 0) {
|
|
e.preventDefault();
|
|
navigator.clipboard.writeText(JSON.stringify({ app: 'hydro-builder-clip', parts: selectedParts }));
|
|
addToast(`Copied ${selectedParts.length} parts`, 'info');
|
|
}
|
|
return;
|
|
}
|
|
if (mod && e.key.toLowerCase() === 'v') {
|
|
e.preventDefault();
|
|
navigator.clipboard.readText().then((text) => {
|
|
try {
|
|
const data = JSON.parse(text);
|
|
if (data && data.app === 'hydro-builder-clip' && Array.isArray(data.parts)) {
|
|
const pastedIds = s.pasteParts(data.parts);
|
|
addToast(`Pasted ${pastedIds.length} parts`, 'success');
|
|
} else {
|
|
addToast('Clipboard does not contain valid hydro parts', 'error');
|
|
}
|
|
} catch (err) {
|
|
addToast(err instanceof Error ? `Paste failed: ${err.message}` : 'Clipboard does not contain valid hydro parts', 'error');
|
|
}
|
|
}).catch(() => {
|
|
addToast('Could not read clipboard', 'error');
|
|
});
|
|
return;
|
|
}
|
|
if (mod) return; // don't hijack other browser shortcuts
|
|
|
|
const hotbarType = getHotbarPartForKey(e.key);
|
|
if (hotbarType) {
|
|
e.preventDefault();
|
|
if (s.tool !== 'select') s.setTool('select');
|
|
s.setPlacingType(s.placingType === hotbarType ? null : hotbarType);
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case ' ':
|
|
e.preventDefault(); // keep Space from scrolling / re-clicking buttons
|
|
s.toggleSimRunning();
|
|
break;
|
|
case 'Escape':
|
|
if (s.colorPopover) {
|
|
s.closeColorPopover();
|
|
} else {
|
|
s.setTool('select');
|
|
s.setSelected(null);
|
|
}
|
|
break;
|
|
case 'Delete':
|
|
case 'Backspace':
|
|
s.removeParts(s.selectedIds);
|
|
break;
|
|
case 'r':
|
|
case 'R':
|
|
s.rotateParts(s.selectedIds, 1, (e.shiftKey ? -1 : 1) * (Math.PI / 2));
|
|
break;
|
|
case 'd':
|
|
case 'D':
|
|
e.preventDefault();
|
|
s.duplicateParts(s.selectedIds);
|
|
break;
|
|
case 'q':
|
|
case 'Q':
|
|
s.translateParts(s.selectedIds, [0, -s.gridSize, 0], 'nudge');
|
|
break;
|
|
case 'e':
|
|
case 'E':
|
|
s.translateParts(s.selectedIds, [0, s.gridSize, 0], 'nudge');
|
|
break;
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, []);
|
|
}
|
|
|
|
import { PaywallModal } from './payments/PaywallModal';
|
|
|
|
function BuilderApp() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const [showPaywall, setShowPaywall] = useState(false);
|
|
const email = user?.email;
|
|
|
|
useBootstrap(email, authLoading);
|
|
useAutosave(email, authLoading);
|
|
useShortcuts();
|
|
const { remoteCursors } = useCollab();
|
|
const placingType = useBuilder((s) => s.placingType);
|
|
const tool = useBuilder((s) => s.tool);
|
|
const selectedCount = useBuilder((s) => s.selectedIds.length);
|
|
const viewMode = useBuilder((s) => s.viewMode);
|
|
const renderMode = useBuilder((s) => s.renderMode);
|
|
const parts = useBuilder((s) => s.parts);
|
|
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
|
const sim = useSimulation();
|
|
const [bomOpen, setBomOpen] = useState(false);
|
|
const [dashboardOpen, setDashboardOpen] = useState(false);
|
|
const [checkFindings, setCheckFindings] = useState<Finding[] | null>(null);
|
|
|
|
const roomParts = useMemo(() => {
|
|
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
|
|
}, [parts, activeRoomId]);
|
|
|
|
const runDesignCheck = () => {
|
|
setCheckFindings(validateSystem(roomParts, sim));
|
|
};
|
|
|
|
const applyFindingFix = (finding: Finding) => {
|
|
const s = useBuilder.getState();
|
|
|
|
if (finding.id.startsWith('dead-leg:')) {
|
|
s.fixDeadLeg(finding.partIds);
|
|
} else if (finding.id.startsWith('diameter-mismatch:')) {
|
|
s.fixDiameterMismatch(finding.partIds);
|
|
} else if (finding.id.startsWith('pump-dead:')) {
|
|
const pumpId = finding.partIds[0];
|
|
if (pumpId) {
|
|
const p = s.parts[pumpId];
|
|
if (p && (p.params.on === 0 || p.params.enabled === 0)) {
|
|
s.updatePart(pumpId, { params: { ...p.params, on: 1, enabled: 1 } });
|
|
} else {
|
|
s.fixPumpDeadEnd(pumpId);
|
|
}
|
|
}
|
|
} else if (finding.id.startsWith('pump-head:')) {
|
|
const pumpId = finding.partIds[0];
|
|
if (pumpId) {
|
|
const p = s.parts[pumpId];
|
|
if (p) {
|
|
const requiredHead = Math.ceil((p.params.maxHeadFt || 8) * 1.5);
|
|
s.updatePart(pumpId, { params: { ...p.params, maxHeadFt: Math.min(30, requiredHead) } });
|
|
}
|
|
}
|
|
} else if (finding.id.startsWith('unsupported-span:')) {
|
|
const pipeId = finding.partIds[0];
|
|
if (pipeId) {
|
|
const p = s.parts[pipeId];
|
|
if (p) {
|
|
const y = p.position[1];
|
|
const supportHeight = Math.max(0.5, y - 0.1);
|
|
const latticeId = s.addPart('lattice', [p.position[0], supportHeight / 2, p.position[2]], { snap: false });
|
|
const lattice = useBuilder.getState().parts[latticeId];
|
|
if (lattice) {
|
|
s.updatePart(latticeId, { params: { ...lattice.params, height: supportHeight } });
|
|
}
|
|
}
|
|
}
|
|
} else if (finding.id.startsWith('drain-capacity:')) {
|
|
const drainId = finding.partIds[0];
|
|
if (drainId) {
|
|
const p = s.parts[drainId];
|
|
if (p) {
|
|
const reported = sim.drains?.find((d) => d?.partId === drainId)?.inflowGph;
|
|
const inflow = reported ?? sim.flows?.[drainId] ?? 0;
|
|
const needed = Math.ceil(inflow / 25) * 25;
|
|
s.updatePart(drainId, { params: { ...p.params, capacityGph: Math.min(2000, needed) } });
|
|
}
|
|
}
|
|
} else if (finding.id.startsWith('res-temp-high:')) {
|
|
const chillers = Object.values(s.parts).filter((p) => p.type === 'waterChiller');
|
|
for (const chiller of chillers) {
|
|
s.updatePart(chiller.id, { params: { ...chiller.params, on: 1, targetTemp: 68 } });
|
|
}
|
|
} else if (finding.id.startsWith('res-ph-low:') || finding.id.startsWith('res-ph-high:')) {
|
|
const resId = finding.partIds[0];
|
|
if (resId) {
|
|
const p = s.parts[resId];
|
|
if (p) s.updatePart(resId, { params: { ...p.params, ph: 6.0 } });
|
|
}
|
|
} else if (finding.id.startsWith('res-ec-low:') || finding.id.startsWith('res-ec-high:')) {
|
|
const resId = finding.partIds[0];
|
|
if (resId) {
|
|
const p = s.parts[resId];
|
|
if (p) s.updatePart(resId, { params: { ...p.params, ec: 1.2 } });
|
|
}
|
|
} else if (finding.id.startsWith('room-co2-open:') || finding.id.startsWith('room-co2-missing:') || finding.id.startsWith('room-co2-starved:')) {
|
|
const tentId = finding.partIds[0];
|
|
if (tentId) {
|
|
const p = s.parts[tentId];
|
|
if (p) {
|
|
// If the CO2 open warning is active, seal the room. If CO2 is starved/missing, unseal the room to swap with fresh air.
|
|
const isCo2Open = finding.id.startsWith('room-co2-open:');
|
|
s.updatePart(tentId, { params: { ...p.params, sealed: isCo2Open ? 1 : 0 } });
|
|
}
|
|
}
|
|
} else if (finding.id.startsWith('room-sealed-exhaust:')) {
|
|
const tentId = finding.partIds[0];
|
|
if (tentId) {
|
|
const p = s.parts[tentId];
|
|
if (p) s.updatePart(tentId, { params: { ...p.params, sealed: 0 } });
|
|
}
|
|
} else if (finding.id.startsWith('room-vent-low:')) {
|
|
const fans = Object.values(s.parts).filter((p) => p.type === 'inlineFan');
|
|
for (const fan of fans) {
|
|
s.updatePart(fan.id, { params: { ...fan.params, cfm: 600 } });
|
|
}
|
|
} else if (finding.id.startsWith('room-light-low:')) {
|
|
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
|
|
for (const light of lights) {
|
|
s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } });
|
|
}
|
|
} else if (finding.id.startsWith('room-light-high:')) {
|
|
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
|
|
for (const light of lights) {
|
|
s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 200) } });
|
|
}
|
|
} else if (finding.id.startsWith('room-dli-low:')) {
|
|
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
|
|
for (const light of lights) {
|
|
s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.min(24, (light.params.photoperiod ?? 18) + 4) } });
|
|
}
|
|
} else if (finding.id.startsWith('room-dli-high:')) {
|
|
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
|
|
for (const light of lights) {
|
|
s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.max(0, (light.params.photoperiod ?? 18) - 6) } });
|
|
}
|
|
} else if (finding.id.startsWith('room-vpd-low:')) {
|
|
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
|
|
for (const light of lights) {
|
|
s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } });
|
|
}
|
|
} else if (finding.id.startsWith('room-vpd-high:')) {
|
|
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
|
|
for (const light of lights) {
|
|
s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 150) } });
|
|
}
|
|
} else if (finding.id.startsWith('closed-valve:')) {
|
|
const valveId = finding.partIds[0];
|
|
if (valveId) {
|
|
const p = s.parts[valveId];
|
|
if (p) s.updatePart(valveId, { params: { ...p.params, open: 100 } });
|
|
}
|
|
} else if (finding.id.startsWith('part-overlap:')) {
|
|
const ids = finding.partIds;
|
|
if (ids.length >= 2) {
|
|
s.removeParts([ids[1]]);
|
|
}
|
|
} else if (finding.id.startsWith('nutrient-depletion:')) {
|
|
const resId = finding.partIds[0];
|
|
if (resId) {
|
|
const p = s.parts[resId];
|
|
if (p) {
|
|
s.updatePart(resId, {
|
|
params: {
|
|
...p.params,
|
|
width: Math.min(5, (p.params.width ?? 2) * 1.5),
|
|
depth: Math.min(4, (p.params.depth ?? 1.4) * 1.5),
|
|
height: Math.min(3, (p.params.height ?? 1) * 1.2),
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} else if (finding.id.startsWith('orphan:')) {
|
|
s.removeParts(finding.partIds);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const nextParts = Object.values(useBuilder.getState().parts);
|
|
setCheckFindings(validateSystem(nextParts, sim));
|
|
}, 50);
|
|
};
|
|
|
|
/** Handle HTML5 drag-and-drop from the parts library onto the canvas. */
|
|
const onDrop = (e: React.DragEvent) => {
|
|
const type = e.dataTransfer.getData('application/x-hydro-part') as PartType;
|
|
if (!type) return;
|
|
e.preventDefault();
|
|
const ground = clientToGround(e.clientX, e.clientY);
|
|
if (!ground) return;
|
|
const s = useBuilder.getState();
|
|
const snap = (v: number) => (s.snapToGrid ? snapValue(v, s.gridSize) : v);
|
|
const y = ['pipe', 'elbow', 'tee', 'valve', 'emitter'].includes(type) ? 0.25 : 0;
|
|
s.addPart(type, [snap(ground[0]), y, snap(ground[2])]);
|
|
};
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="flex h-screen flex-col items-center justify-center bg-zinc-950 text-zinc-100">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-sky-500 border-t-transparent mb-4" />
|
|
<span className="text-sm font-semibold tracking-wider text-zinc-400">Hydro Builder initializing...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return <LoginModal />;
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col bg-zinc-950 text-zinc-100 select-none">
|
|
<Toolbar
|
|
onOpenBom={() => setBomOpen(true)}
|
|
onCheckBuild={runDesignCheck}
|
|
onOpenDashboard={() => setDashboardOpen(true)}
|
|
/>
|
|
<div className="flex min-h-0 flex-1">
|
|
<PartLibrary />
|
|
<main
|
|
className="relative min-w-0 flex-1"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={onDrop}
|
|
>
|
|
<SceneCanvas remoteCursors={remoteCursors} />
|
|
{tool === 'measure' && (
|
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 rounded-full border border-sky-500/30 bg-zinc-950/80 px-4 py-1.5 text-xs font-semibold text-sky-400 shadow-lg backdrop-blur">
|
|
📏 Measurement mode: Click two points in the 3D scene
|
|
<button
|
|
onClick={() => useBuilder.getState().setTool('select')}
|
|
className="ml-3 rounded bg-sky-900/50 px-2 py-0.5 text-sky-200 hover:bg-sky-800/50"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* Premium Upsell Bar */}
|
|
{!user || (user as any).purchased_slots === 0 ? (
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center justify-between gap-4 rounded-xl border border-amber-500/30 bg-gradient-to-r from-zinc-950/95 via-amber-950/40 to-zinc-950/95 px-6 py-3 shadow-2xl backdrop-blur">
|
|
<div>
|
|
<h4 className="text-sm font-bold text-amber-400 tracking-wide uppercase">Unlock Pro Premium</h4>
|
|
<p className="text-[11px] text-zinc-300 mt-0.5">One-time $5 special offer. Unlimited projects, PDF shopping lists, CAD exports, and drjonesbot.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowPaywall(true)}
|
|
className="whitespace-nowrap rounded-lg bg-amber-500 px-4 py-2 text-xs font-bold text-amber-950 transition hover:bg-amber-400 hover:scale-105"
|
|
>
|
|
Join Now
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{/* Contextual hint overlay */}
|
|
{(placingType || tool === 'measure' || tool === 'pipeRun') && (
|
|
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 rounded-full border border-sky-800 bg-sky-950/90 px-4 py-1.5 text-xs font-medium text-sky-200 shadow-lg">
|
|
{placingType
|
|
? 'Click to place · Shift-click to stamp copies · 1-9 switches parts · Esc to cancel'
|
|
: tool === 'pipeRun'
|
|
? 'Pipe Run: click points · Shift-click to keep routing · Esc to exit'
|
|
: 'Measure: click two points · Esc to exit'}
|
|
</div>
|
|
)}
|
|
{/* Selection hint */}
|
|
{!placingType && tool !== 'measure' && selectedCount > 0 && (
|
|
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-zinc-800 bg-zinc-950/85 px-4 py-1.5 text-[11px] font-medium text-zinc-400 shadow-lg backdrop-blur">
|
|
{selectedCount > 1
|
|
? `${selectedCount} parts — drag moves all · ⌥drag clones · ⇧click adds/removes · ⌘click color · D duplicate · ⌫ delete`
|
|
: `Drag body or gizmo to move · double-click plumbing to grab the whole run · selected pipes show stretch handles · drag a connector dot to snap${viewMode === 'orbit' ? ' · ⇧drag for vertical' : ''} · ${renderMode === 'xray' ? 'X-Ray shows internals' : renderMode === 'water' ? 'Water mode isolates flow' : 'Builder mode keeps solids primary'} · ⌥drag clones · ⌘click color · R rotate`}
|
|
</div>
|
|
)}
|
|
<ColorPopover />
|
|
</main>
|
|
<PropertiesPanel />
|
|
</div>
|
|
<StatusPanel />
|
|
{bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
|
|
{dashboardOpen && <ScheduleDashboard onClose={() => setDashboardOpen(false)} />}
|
|
{checkFindings && (
|
|
<DesignCheckPanel
|
|
findings={checkFindings}
|
|
onSelectPart={(id) => {
|
|
const s = useBuilder.getState();
|
|
s.setSelected(id);
|
|
if (id) {
|
|
const part = s.parts[id];
|
|
if (part) s.setFocusTarget(part.position);
|
|
}
|
|
}}
|
|
onApplyFix={applyFindingFix}
|
|
onClose={() => setCheckFindings(null)}
|
|
/>
|
|
)}
|
|
<TestimonialScroller />
|
|
<ToastContainer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<BuilderApp />
|
|
</AuthProvider>
|
|
);
|
|
}
|