This commit is contained in:
Andras Schmelczer 2026-09-11 16:09:28 +01:00
commit d8f2407ddf
44 changed files with 10377 additions and 0 deletions

259
src/App.tsx Normal file
View file

@ -0,0 +1,259 @@
import { useMemo, useRef } from 'react';
import { Copy, Plus, Printer, ArrowDown } from 'lucide-react';
import { useProject } from './lib/use-project';
import { rectangle, nextId } from './lib/defaults';
import { buildGeometry } from './lib/geometry';
import { planFlooring } from './lib/flooring';
import { planSkirting } from './lib/skirting';
import { area, mm } from './lib/format';
import { isSkirtingCut } from './lib/cut-list';
import type { Project, Room } from './lib/types';
import { OutlineEditor } from './components/OutlineEditor';
import { MaterialsEditor } from './components/MaterialsEditor';
import { FloorPlan } from './components/FloorPlan';
import { CutList } from './components/CutList';
import { Issues } from './components/Fields';
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
export default function App() {
const { state, error, update } = useProject();
const { project, view } = state;
const copyStatus = useRef<HTMLOutputElement>(null);
const room = project.rooms.find((r) => r.id === view.roomId) ?? project.rooms[0];
const geometries = useMemo(
() => project.rooms.map((r) => buildGeometry(r, project.tolerance)),
[project.rooms, project.tolerance],
);
const geometry = geometries[project.rooms.indexOf(room)];
const flooring = useMemo(
() => planFlooring(project.rooms, project.flooring, project.tolerance),
[project.rooms, project.flooring, project.tolerance],
);
const skirting = useMemo(
() => planSkirting(project.rooms, project.skirting, project.tolerance),
[project.rooms, project.skirting, project.tolerance],
);
const floorErrors = flooring.issues.filter((i) => i.severity === 'error');
const skirtErrors = skirting.issues.filter((i) => i.severity === 'error');
const roomErrors = [...floorErrors, ...skirtErrors].filter(
(issue) => !issue.roomId || issue.roomId === room.id,
);
const totalArea = geometries.reduce((sum, g) => sum + (g.valid ? g.area : 0), 0);
const wholeBoards = flooring.pieces.filter(
(p) =>
!p.cut &&
Math.abs(p.length - project.flooring.length) < 0.01 &&
Math.abs(p.width - project.flooring.width) < 0.01,
).length;
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
const changeProject = (fn: (p: Project) => Project) =>
update((s) => ({ ...s, project: fn(s.project), view: { ...s.view, selectedPiece: null } }));
const changeRoom = (fn: (r: Room) => Room) =>
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
const select = (id: string | null, roomId?: string) =>
update((s) => ({
...s,
view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id },
}));
const addRoom = () =>
update((s) => {
const id = nextId(
'room',
s.project.rooms.map((r) => r.id),
);
return {
...s,
project: {
...s.project,
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
},
view: { ...s.view, roomId: id, selectedPiece: null },
};
}, true);
const removeRoom = () =>
update((s) => {
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
return {
...s,
project: { ...s.project, rooms },
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null },
};
}, true);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
} catch {
if (copyStatus.current)
copyStatus.current.textContent = 'Copy the address from your browser.';
}
};
return (
<>
<header className="app-header">
<div className="app-title">
<span className="square-mark" aria-hidden="true" />
<h1>Floor planner</h1>
</div>
<div className="header-actions">
<button className="button" onClick={copyLink}>
<Copy size={17} />
Copy link
</button>
<button
className="button"
onClick={() => window.print()}
disabled={floorErrors.length > 0 || skirtErrors.length > 0}
>
<Printer size={17} />
<span>Print</span>
</button>
</div>
</header>
<main className="app-main">
<output ref={copyStatus} className="copy-status" aria-live="polite" />
{error && (
<p className="url-error" role="alert">
{error}
</p>
)}
<div className="project-bar">
<div className="room-tabs" aria-label="Rooms">
{project.rooms.map((r, index) => (
<button
key={r.id}
className={`room-tab ${r.id === room.id ? 'active' : ''}`}
aria-pressed={r.id === room.id}
onClick={() =>
update(
(s) => ({ ...s, view: { ...s.view, roomId: r.id, selectedPiece: null } }),
true,
)
}
>
{r.name || 'Untitled room'}
<small>
{geometries[index].valid ? `${area(geometries[index].area)} m²` : 'Check outline'}
</small>
</button>
))}
<button className="add-room" onClick={addRoom} disabled={project.rooms.length >= 20}>
<Plus size={17} />
Add room
</button>
</div>
<span className="project-area">{area(totalArea)} m² total</span>
</div>
<nav className="page-jumps" aria-label="Jump to section">
<button onClick={() => jump('measurements')}>
Room <ArrowDown size={13} />
</button>
<button onClick={() => jump('materials')}>
Materials <ArrowDown size={13} />
</button>
<button onClick={() => jump('drawing')}>
Drawing <ArrowDown size={13} />
</button>
<button onClick={() => jump('cut-plan')}>
Cuts <ArrowDown size={13} />
</button>
</nav>
<div className="workspace">
<div className="input-columns">
<section
className="editor-panel room-editor"
id="measurements"
aria-label="Room measurements"
>
<OutlineEditor
room={room}
geometry={geometry}
tolerance={project.tolerance}
changeRoom={changeRoom}
changeProject={changeProject}
remove={removeRoom}
canRemove={project.rooms.length > 1}
/>
</section>
<section
className="editor-panel materials-editor"
id="materials"
aria-label="Materials"
>
<MaterialsEditor
project={project}
room={room}
changeRoom={changeRoom}
changeProject={changeProject}
/>
</section>
</div>
<div className="drawing-column" id="drawing">
<FloorPlan
room={room}
geometry={geometry}
flooring={flooring}
skirting={skirting}
selectedPiece={view.selectedPiece}
select={(id) => select(id)}
/>
<section className="purchase-summary" aria-label="Materials to buy">
<h2>
Materials to buy <small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
</h2>
<div className="purchase-item flooring">
<span className="material-key floor-key" aria-hidden="true" />
<div>
<strong>
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
</strong>
<p>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm · {wholeBoards}{' '}
used whole
</p>
</div>
<span className="waste-stat">
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
</span>
</div>
<div className="purchase-item skirting">
<span className="material-key skirt-key" aria-hidden="true" />
<div>
<strong>
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
</strong>
<p>
{[...new Set(skirting.stocks.map((s) => s.length))]
.sort((a, b) => a - b)
.map(
(l) =>
`${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
)
.join(' + ') || 'Check settings'}{' '}
· {wholeSkirting} used whole
</p>
</div>
<span className="waste-stat">
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
</span>
</div>
</section>
<Issues issues={roomErrors} />
</div>
</div>
<div id="cut-plan">
<CutList
project={project}
flooring={flooring}
skirting={skirting}
selectedPiece={view.selectedPiece}
select={select}
/>
</div>
</main>
</>
);
}