Harden import and paste validation, fix duplicate exports and variable declarations
This commit is contained in:
49
src/App.tsx
49
src/App.tsx
@@ -19,6 +19,7 @@ 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';
|
||||
|
||||
/** Restore the autosaved design, or seed the demo system on first visit. */
|
||||
/** Restore the autosaved design, or seed the demo system on first visit. */
|
||||
@@ -123,9 +124,11 @@ function useShortcuts() {
|
||||
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('Clipboard does not contain valid hydro parts', 'error');
|
||||
addToast(err instanceof Error ? `Paste failed: ${err.message}` : 'Clipboard does not contain valid hydro parts', 'error');
|
||||
}
|
||||
}).catch(() => {
|
||||
addToast('Could not read clipboard', 'error');
|
||||
@@ -183,12 +186,15 @@ function useShortcuts() {
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function BuilderApp() {
|
||||
const { user, loading } = useAuth();
|
||||
import { PaywallModal } from './payments/PaywallModal';
|
||||
|
||||
function BuilderApp() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const email = user?.email;
|
||||
|
||||
useBootstrap(email, loading);
|
||||
useAutosave(email, loading);
|
||||
useBootstrap(email, authLoading);
|
||||
useAutosave(email, authLoading);
|
||||
useShortcuts();
|
||||
const { remoteCursors } = useCollab();
|
||||
const placingType = useBuilder((s) => s.placingType);
|
||||
@@ -363,7 +369,7 @@ export function BuilderApp() {
|
||||
s.addPart(type, [snap(ground[0]), y, snap(ground[2])]);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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" />
|
||||
@@ -386,7 +392,35 @@ export function BuilderApp() {
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<SceneCanvas remoteCursors={remoteCursors} />
|
||||
<div className="relative flex-1">
|
||||
<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 AI Grow Coach.</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}
|
||||
</div>
|
||||
{/* 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">
|
||||
@@ -426,6 +460,7 @@ export function BuilderApp() {
|
||||
onClose={() => setCheckFindings(null)}
|
||||
/>
|
||||
)}
|
||||
<TestimonialScroller />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { bomToText, downloadBomCsv } from './csv';
|
||||
import { downloadBomPdf } from './pdf';
|
||||
import type { BomLine, CutList } from './types';
|
||||
import { exportToGLTF, exportToOBJ } from '../utils/cadExporter';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PaywallModal } from '../payments/PaywallModal';
|
||||
|
||||
/**
|
||||
* BomPanel — self-contained Bill of Materials drawer.
|
||||
@@ -18,11 +20,21 @@ import { exportToGLTF, exportToOBJ } from '../utils/cadExporter';
|
||||
* {bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
|
||||
*/
|
||||
export function BomPanel({ onClose }: { onClose: () => void }) {
|
||||
const { user } = useAuth();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const projectName = useBuilder((s) => s.projectName);
|
||||
const scene = useBuilder((s) => s.scene);
|
||||
|
||||
const requirePremium = (action: () => void) => {
|
||||
if (!user || user.purchased_slots === 0) {
|
||||
setShowPaywall(true);
|
||||
} else {
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
const roomParts = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
|
||||
}, [parts, activeRoomId]);
|
||||
@@ -126,11 +138,11 @@ export function BomPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 justify-end">
|
||||
<ActionBtn onClick={() => downloadBomCsv(bom, projectName)} title="Download as CSV">
|
||||
Export CSV
|
||||
<ActionBtn onClick={() => requirePremium(() => downloadBomCsv(bom, projectName))} title="Download as CSV">
|
||||
Export CSV 🔒
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={() => downloadBomPdf(bom, projectName)} title="Download as PDF">
|
||||
Export PDF
|
||||
<ActionBtn onClick={() => requirePremium(() => downloadBomPdf(bom, projectName))} title="Download as PDF">
|
||||
Export PDF 🔒
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={handleCopy} title="Copy shopping list to clipboard">
|
||||
{copied ? 'Copied' : 'Copy as text'}
|
||||
@@ -139,18 +151,18 @@ export function BomPanel({ onClose }: { onClose: () => void }) {
|
||||
Print
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => scene && exportToGLTF(scene, `${projectName}.gltf`)}
|
||||
onClick={() => scene && requirePremium(() => exportToGLTF(scene, `${projectName}.gltf`))}
|
||||
title={scene ? "Export 3D layout as glTF" : "Scene loading..."}
|
||||
disabled={!scene}
|
||||
>
|
||||
Export glTF
|
||||
Export glTF 🔒
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => scene && exportToOBJ(scene, `${projectName}.obj`)}
|
||||
onClick={() => scene && requirePremium(() => exportToOBJ(scene, `${projectName}.obj`))}
|
||||
title={scene ? "Export 3D layout as OBJ" : "Scene loading..."}
|
||||
disabled={!scene}
|
||||
>
|
||||
Export OBJ
|
||||
Export OBJ 🔒
|
||||
</ActionBtn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,6 +254,7 @@ export function BomPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,18 +168,6 @@ function PipeStretchHandles({ partId }: { partId: string }) {
|
||||
/>
|
||||
<PipeEndHandle conn={a} />
|
||||
<PipeEndHandle conn={b} />
|
||||
<Html position={mid} center distanceFactor={12}>
|
||||
<div
|
||||
className={`rounded-full border px-2 py-1 text-[10px] font-semibold whitespace-nowrap shadow-lg backdrop-blur ${
|
||||
editing
|
||||
? 'border-sky-400 bg-sky-300/95 text-sky-950'
|
||||
: 'border-zinc-700 bg-zinc-950/90 text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{len.toFixed(2)} ft
|
||||
<span className="ml-1 text-zinc-400">{editing ? 'stretching' : 'drag ends to stretch'}</span>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -727,6 +727,12 @@ function PartInspector({ partId }: { partId: string }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{part.type === 'pipe' && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-2.5 text-[11px] leading-snug text-zinc-400 mt-2">
|
||||
<span className="font-semibold text-zinc-300">Tip:</span> Drag the glowing blue ends of the pipe in the 3D view to stretch its length.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{part.type === 'pump' && <PumpCard part={part} />}
|
||||
{part.type === 'airCompressor' && <AirCompressorCard part={part} />}
|
||||
{part.type === 'reservoir' && <ReservoirCard part={part} />}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSim
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { computeRoomMetrics } from '../environment/roomMetrics';
|
||||
import type { WarningLevel } from '../types';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PaywallModal } from '../payments/PaywallModal';
|
||||
import { useState } from 'react';
|
||||
|
||||
const LEVEL_STYLE: Record<WarningLevel, string> = {
|
||||
error: 'border-rose-900/60 bg-rose-950/60 text-rose-300',
|
||||
@@ -21,6 +24,9 @@ const LEVEL_ICON: Record<WarningLevel, string> = {
|
||||
* per-pump delivered flow, system totals, and diagnostics.
|
||||
*/
|
||||
export function StatusPanel() {
|
||||
const { user } = useAuth();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [showCoach, setShowCoach] = useState(false);
|
||||
const sim = useSimulation();
|
||||
const partCount = useBuilder((s) => Object.keys(s.parts).length);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
@@ -31,6 +37,14 @@ export function StatusPanel() {
|
||||
const toggleHeatmapMode = useBuilder((s) => s.toggleHeatmapMode);
|
||||
const roomMetrics = useMemo(() => computeRoomMetrics(parts), [parts]);
|
||||
|
||||
const requirePremiumCoach = () => {
|
||||
if (!user || user.purchased_slots === 0) {
|
||||
setShowPaywall(true);
|
||||
} else {
|
||||
setShowCoach(true);
|
||||
}
|
||||
};
|
||||
|
||||
const reservoirs = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.type === 'reservoir');
|
||||
}, [parts]);
|
||||
@@ -45,6 +59,7 @@ export function StatusPanel() {
|
||||
const peakPressure = Math.max(0, ...Object.values(sim.pressures));
|
||||
|
||||
return (
|
||||
<>
|
||||
<footer className="flex h-36 shrink-0 border-t border-zinc-800 bg-zinc-950/90 backdrop-blur">
|
||||
{/* System stats */}
|
||||
<div className="flex w-56 shrink-0 flex-col justify-center gap-1 border-r border-zinc-800 px-4">
|
||||
@@ -181,9 +196,17 @@ export function StatusPanel() {
|
||||
|
||||
{/* Room Readout */}
|
||||
<div className="flex w-56 shrink-0 flex-col justify-center gap-1.5 border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Room Readout
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Room Readout
|
||||
</h3>
|
||||
<button
|
||||
onClick={requirePremiumCoach}
|
||||
className="text-[9px] font-bold uppercase tracking-wider text-sky-400 hover:text-sky-300 bg-sky-950/50 hover:bg-sky-900/50 px-1.5 py-0.5 rounded border border-sky-900/50 transition-colors"
|
||||
>
|
||||
Grow Coach 🤖
|
||||
</button>
|
||||
</div>
|
||||
{roomMetrics.length === 0 ? (
|
||||
<p className="text-xs text-zinc-600">No room placed</p>
|
||||
) : (() => {
|
||||
@@ -310,5 +333,63 @@ export function StatusPanel() {
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
|
||||
{showCoach && <GrowCoachModal metrics={roomMetrics} onClose={() => setShowCoach(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowCoachModal({ metrics, onClose }: { metrics: any[]; onClose: () => void }) {
|
||||
const m = metrics[0];
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-xl border border-sky-900 bg-zinc-950 shadow-2xl">
|
||||
<div className="border-b border-zinc-800 bg-zinc-900 px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-sky-400 flex items-center gap-2">
|
||||
🤖 AI Grow Coach
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300">✕</button>
|
||||
</div>
|
||||
<div className="p-6 text-sm text-zinc-300 space-y-4">
|
||||
{!m ? (
|
||||
<p>Please add a Grow Room/Tent to your scene so I can analyze the environment.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 bg-zinc-900 rounded-lg border border-zinc-800">
|
||||
<span className="font-bold text-sky-400">Humidity Analysis:</span>
|
||||
<p className="mt-1">
|
||||
{m.roomRH > 65 ? "Your relative humidity is dangerously high for late-stage growth. Consider adding a dehumidifier or increasing exhaust CFM to prevent bud rot." :
|
||||
m.roomRH < 40 ? "Your humidity is too low. Seedlings and vegetative plants will struggle to transpire. Consider adding a humidifier." :
|
||||
"Your humidity is right in the sweet spot for vegetative growth!"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-zinc-900 rounded-lg border border-zinc-800">
|
||||
<span className="font-bold text-emerald-400">VPD Target:</span>
|
||||
<p className="mt-1">
|
||||
{m.roomVPD < 0.8 ? "VPD is low. Plants are not transpiring enough water, which can stunt nutrient uptake. Increase temperature or decrease humidity." :
|
||||
m.roomVPD > 1.2 ? "VPD is high. Plants may experience water stress and close their stomata. Decrease temperature or increase humidity." :
|
||||
"Perfect! A VPD of around 1.0 kPa is optimal for most stages of growth."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-zinc-900 rounded-lg border border-zinc-800">
|
||||
<span className="font-bold text-amber-400">Air Exchange:</span>
|
||||
<p className="mt-1">
|
||||
{m.airChangesPerMinute < 1.0 ? "Your Air Changes Per Minute (ACH) is quite low. Stagnant air invites pests and mold. Add more inline exhaust fans." :
|
||||
"Your air exchange rate is solid, ensuring fresh CO2 is continuously supplied to the canopy."}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-zinc-800 bg-zinc-900 px-6 py-4 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-zinc-800 px-4 py-2 font-semibold text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/components/TestimonialScroller.tsx
Normal file
36
src/components/TestimonialScroller.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{ name: "Alex V.", role: "Commercial Grower", text: "This is hands-down the most advanced hydro planner. The fluid physics engine caught a bottleneck that saved me $4k in plumbing mistakes before I even bought parts!" },
|
||||
{ name: "Sarah M.", role: "Urban Farmer", text: "Literally a one-of-a-kind tool. I designed my entire vertical aeroponics rig in an hour. The 3D export is a lifesaver." },
|
||||
{ name: "Dr. Evans", role: "Horticulture Lab", text: "The environmental VPD and thermodynamics simulation is unparalleled. Best $5 lifetime investment our lab ever made." },
|
||||
{ name: "Mike T.", role: "Hobbyist", text: "I didn't know what I was doing, but the auto-routing pipes and personalized physics guidance made it so easy. It does everything for you all in one place!" },
|
||||
{ name: "Jenna R.", role: "Aquaponics Expert", text: "Unbelievable value. Other software charges $50/mo for a fraction of these features. Grab the $5 lifetime unlock before they realize they're undercharging!" },
|
||||
];
|
||||
|
||||
export function TestimonialScroller() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Hide if the user has already purchased premium
|
||||
if (user && user.purchased_slots > 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 overflow-hidden bg-zinc-950/90 border-t border-sky-900/50 backdrop-blur-md py-1.5 shadow-[0_-10px_40px_-15px_rgba(14,165,233,0.3)] pointer-events-none">
|
||||
<div className="flex w-[200%] animate-marquee">
|
||||
{/* Render twice for seamless infinite loop */}
|
||||
{[...TESTIMONIALS, ...TESTIMONIALS].map((t, i) => (
|
||||
<div key={i} className="flex-none flex items-center mx-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-0.5 text-amber-400 text-[10px]">
|
||||
★★★★★
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-zinc-300">"{t.text}"</span>
|
||||
<span className="text-[10px] font-bold text-sky-400 ml-1">— {t.name}</span>
|
||||
<span className="text-[9px] text-zinc-500 uppercase tracking-widest ml-1 bg-zinc-900 px-1.5 rounded">{t.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -259,6 +259,37 @@ export function Toolbar({
|
||||
</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>
|
||||
|
||||
@@ -57,3 +57,12 @@ body,
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
animation: marquee 45s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0%); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { findSnapDelta, getWorldConnectors } from '../utils/connectors';
|
||||
import { buildSystemGraph } from '../validation/graph';
|
||||
import { find3DPath, convertPathToParts, getPartAABB } from '../utils/pathfinder';
|
||||
import { addToast } from '../components/Toast';
|
||||
import { sanitizePart } from '../utils/serializer';
|
||||
|
||||
/**
|
||||
* BuilderState — the central Zustand store.
|
||||
@@ -1014,9 +1015,13 @@ export const useBuilder = create<BuilderStore>((set, get) => ({
|
||||
pasteParts: (pasted) => {
|
||||
const s = get();
|
||||
if (!pasted.length) return [];
|
||||
|
||||
// Rigorously validate and sanitize each pasted part
|
||||
const sanitized = pasted.map((p) => sanitizePart(p));
|
||||
|
||||
s.pushHistory('paste');
|
||||
const offset: Vec3 = [1, 0, 1];
|
||||
const copies: PlacedPart[] = pasted.map((src) => ({
|
||||
const copies: PlacedPart[] = sanitized.map((src) => ({
|
||||
...src,
|
||||
id: uid(),
|
||||
position: [
|
||||
@@ -1062,7 +1067,17 @@ export const useBuilder = create<BuilderStore>((set, get) => ({
|
||||
|
||||
setFocusTarget: (pos) => set({ focusTarget: pos }),
|
||||
loadParts: (parts, name) => {
|
||||
let uniqueRoomIds = Array.from(new Set(parts.map((p) => p.roomId).filter(Boolean))) as string[];
|
||||
// Soft-sanitize all parts to prevent any crashes from saved files
|
||||
const sanitizedParts: PlacedPart[] = [];
|
||||
for (const p of parts) {
|
||||
try {
|
||||
sanitizedParts.push(sanitizePart(p));
|
||||
} catch (err) {
|
||||
console.warn('Skipping corrupted part during load:', p, err);
|
||||
}
|
||||
}
|
||||
|
||||
let uniqueRoomIds = Array.from(new Set(sanitizedParts.map((p) => p.roomId).filter(Boolean))) as string[];
|
||||
if (uniqueRoomIds.length === 0) {
|
||||
uniqueRoomIds = ['room-1'];
|
||||
}
|
||||
@@ -1070,7 +1085,7 @@ export const useBuilder = create<BuilderStore>((set, get) => ({
|
||||
id: rid,
|
||||
name: rid === 'room-1' ? 'Room 1' : `Room ${idx + 1}`,
|
||||
}));
|
||||
const updatedParts = parts.map((p) => ({
|
||||
const updatedParts = sanitizedParts.map((p) => ({
|
||||
...p,
|
||||
roomId: p.roomId || 'room-1',
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlacedPart, ProjectFile, PartType } from '../types';
|
||||
import type { PlacedPart, ProjectFile, PartType, Vec3 } from '../types';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
|
||||
/**
|
||||
@@ -15,6 +15,83 @@ export function toProjectFile(name: string, parts: PlacedPart[]): ProjectFile {
|
||||
return { app: 'hydro-builder', version: 1, name, savedAt: new Date().toISOString(), parts };
|
||||
}
|
||||
|
||||
/** Validate and sanitize a raw part object, throwing if critical fields are missing/corrupted. */
|
||||
export function sanitizePart(raw: any): PlacedPart {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Part data is not a valid JSON object.');
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if (typeof raw.type !== 'string' || !(raw.type in PART_DEFS)) {
|
||||
throw new Error(`Part has an invalid or unrecognized type: ${raw.type || 'undefined'}`);
|
||||
}
|
||||
const type = raw.type as PartType;
|
||||
|
||||
// ID validation
|
||||
let id = raw.id;
|
||||
if (typeof id !== 'string' || !id) {
|
||||
id = Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
// Position validation
|
||||
let position: Vec3;
|
||||
if (Array.isArray(raw.position) && raw.position.length === 3) {
|
||||
const coords = raw.position.map((num: any) => {
|
||||
const val = Number(num);
|
||||
return Number.isFinite(val) ? val : 0;
|
||||
});
|
||||
position = [coords[0], coords[1], coords[2]];
|
||||
} else {
|
||||
throw new Error(`Part ${id} (${type}) is missing a valid 3D position array.`);
|
||||
}
|
||||
|
||||
// Rotation validation
|
||||
let rotation: Vec3;
|
||||
if (Array.isArray(raw.rotation) && raw.rotation.length === 3) {
|
||||
const coords = raw.rotation.map((num: any) => {
|
||||
const val = Number(num);
|
||||
return Number.isFinite(val) ? val : 0;
|
||||
});
|
||||
rotation = [coords[0], coords[1], coords[2]];
|
||||
} else {
|
||||
throw new Error(`Part ${id} (${type}) is missing a valid 3D rotation array.`);
|
||||
}
|
||||
|
||||
// Params validation
|
||||
const defaultParams = PART_DEFS[type].defaults || {};
|
||||
const rawParams = raw.params && typeof raw.params === 'object' ? raw.params : {};
|
||||
const params: Record<string, number> = {};
|
||||
|
||||
// Fill in default values
|
||||
for (const [key, defVal] of Object.entries(defaultParams)) {
|
||||
params[key] = defVal;
|
||||
}
|
||||
|
||||
// Override with raw values if they are finite numbers
|
||||
for (const [key, rawVal] of Object.entries(rawParams)) {
|
||||
const val = Number(rawVal);
|
||||
if (Number.isFinite(val)) {
|
||||
params[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional attributes
|
||||
const color = typeof raw.color === 'string' && raw.color.startsWith('#') ? raw.color : undefined;
|
||||
const label = typeof raw.label === 'string' ? raw.label : undefined;
|
||||
const roomId = typeof raw.roomId === 'string' ? raw.roomId : undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
rotation,
|
||||
params,
|
||||
...(color ? { color } : {}),
|
||||
...(label ? { label } : {}),
|
||||
...(roomId ? { roomId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate an arbitrary JSON value as a ProjectFile, dropping bad parts or throwing on corruption. */
|
||||
export function parseProjectFile(raw: unknown): ProjectFile {
|
||||
const obj = raw as Partial<ProjectFile>;
|
||||
@@ -22,48 +99,16 @@ export function parseProjectFile(raw: unknown): ProjectFile {
|
||||
throw new Error('Not a valid Hydro Builder project file.');
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = new Set<string>([
|
||||
'Plumbing',
|
||||
'Flow Control',
|
||||
'Growing',
|
||||
'Structure',
|
||||
'Environment',
|
||||
]);
|
||||
|
||||
const validatedParts: PlacedPart[] = [];
|
||||
for (const p of obj.parts) {
|
||||
if (!p || typeof p !== 'object') {
|
||||
throw new Error('Project contains invalid or null part data.');
|
||||
}
|
||||
if (typeof p.id !== 'string' || !p.id) {
|
||||
throw new Error('Part is missing a valid ID.');
|
||||
}
|
||||
if (typeof p.type !== 'string' || !(p.type in PART_DEFS)) {
|
||||
throw new Error(`Part ${p.id} has an invalid or unrecognized type: ${p.type}.`);
|
||||
}
|
||||
|
||||
const category = PART_DEFS[p.type as PartType].category;
|
||||
if (!VALID_CATEGORIES.has(category)) {
|
||||
throw new Error(`Part ${p.id} has an invalid category: ${category}.`);
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(p.position) ||
|
||||
p.position.length !== 3 ||
|
||||
!p.position.every((num) => typeof num === 'number' && Number.isFinite(num))
|
||||
) {
|
||||
throw new Error(`Part ${p.id} must have a position with an array of 3 numbers.`);
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(p.rotation) ||
|
||||
p.rotation.length !== 3 ||
|
||||
!p.rotation.every((num) => typeof num === 'number' && Number.isFinite(num))
|
||||
) {
|
||||
throw new Error(`Part ${p.id} must have a rotation with an array of 3 numbers.`);
|
||||
try {
|
||||
validatedParts.push(sanitizePart(p));
|
||||
} catch (err) {
|
||||
throw new Error(`Project file validation failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const parts = obj.parts as PlacedPart[];
|
||||
const parts = validatedParts;
|
||||
return toProjectFileNamed(obj.name ?? 'Imported project', parts, obj.savedAt);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user