Compare commits
17 Commits
mai/picass
...
mai/picass
| Author | SHA1 | Date | |
|---|---|---|---|
| e42b351280 | |||
| e862a06e9d | |||
| 4f862e741a | |||
| 29e221e080 | |||
| c7dfbe010c | |||
| 12804619b2 | |||
| e12b449169 | |||
| 28a376a7f3 | |||
| 6d637e1fac | |||
| 94869f342e | |||
| a9e6d7aa62 | |||
| b15913124a | |||
| 21bf00566c | |||
| cf1671e8c1 | |||
| d3b660d140 | |||
| dc5fafeaa8 | |||
| 017a77e187 |
89
CLAUDE.md
89
CLAUDE.md
@@ -2,11 +2,21 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cable-management **framework** for m's setup. Each cable-managed environment
|
||||
(LOFT, OFFICE, …) is a separate **mCables project**, and each project is
|
||||
backed by exactly one Excalidraw drawing. The framework provides a visual
|
||||
web interface backed by a Go HTTP API and SQLite, plus an export pipeline
|
||||
that writes `.excalidraw` files via mExDraw.
|
||||
Cable-management **framework + solver** for m's setup. m declares his
|
||||
**devices** and the **connection requirements** between them ("NAS must
|
||||
connect to Switch via RJ45"). mCables runs a solver that emits the cable
|
||||
plan + bundle recommendations. mCables is a **schematic**, not a
|
||||
physical-routing tool — cables are straight lines between endpoints; the
|
||||
"maximum bundling" objective is satisfied by the endpoint-pair rule
|
||||
(when two or more cables share the same A↔B endpoint pair, group them
|
||||
into one bundle). The visual editor is still there for tweaking the
|
||||
plan, but the solver is the headline.
|
||||
|
||||
Each cable-managed environment (LOFT, OFFICE, …) is a separate
|
||||
**mCables project**, and each project is backed by exactly one Excalidraw
|
||||
drawing. The framework provides a visual web interface backed by a Go
|
||||
HTTP API and SQLite, plus an export pipeline that writes `.excalidraw`
|
||||
files via mExDraw.
|
||||
|
||||
**Memory group_id:** `mcables`
|
||||
|
||||
@@ -19,13 +29,25 @@ interface. The backend serves the UI and the API; there is no
|
||||
- A reusable framework for tracking devices, ports, cables, cable types,
|
||||
bundles, frames — **scoped per project** (LOFT and OFFICE are separate
|
||||
projects, each a separate drawing).
|
||||
- A visual editor in the browser: switch projects, add frames/devices/ports,
|
||||
click ports to wire up cables, pick cable types from a per-project legend.
|
||||
- A one-way export from the DB to the corresponding `.excalidraw` drawing
|
||||
on `mxdrw.msbls.de` whenever m clicks Export — DB is authoritative,
|
||||
Excalidraw is the projection.
|
||||
- Bundle detection: parallel cables along the same path within a project
|
||||
get grouped + colour-bundled in the diagram.
|
||||
- A **solver** that, given the project's devices + connection
|
||||
requirements, emits the cable plan + bundle recommendations.
|
||||
Objective: maximum bundling via the endpoint-pair rule (schematic
|
||||
only — no path/trunk/cable-tray modelling).
|
||||
- A **hybrid device-type catalog**: 14 built-in types (NAS, PC, Mac,
|
||||
Notebook, TV, Soundbar, Switch, fritz, ChromeCast, SteamLink,
|
||||
IOx-3/6/8, Screen, Keyboard, Mouse) with default port profiles,
|
||||
extensible per project. Picking a type on device-create seeds the
|
||||
device's ports automatically; m overrides per instance.
|
||||
- **Setup templates** for bootstrapping a project from blank to
|
||||
solver-ready: built-ins 'Living Room', 'Home Office', 'Server Rack'
|
||||
stamp their device-types + connection requirements in one transaction.
|
||||
- A visual editor for switching projects, adding frames/devices,
|
||||
declaring requirements, running the solver, and tweaking the
|
||||
resulting plan. Unmet requirements get a one-click quick-fix
|
||||
("+ Add <type> port to <device> and re-solve").
|
||||
- A one-way export from the DB to the corresponding `.excalidraw`
|
||||
drawing on `mxdrw.msbls.de` whenever m clicks Export — DB is
|
||||
authoritative, Excalidraw is the projection.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -45,16 +67,31 @@ interface. The backend serves the UI and the API; there is no
|
||||
- **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`;
|
||||
OFFICE has `desk`, `server`). Frames are not projects — they're zones
|
||||
within one drawing.
|
||||
- Every device, port, cable, IO marker, and bundle is **project-scoped**
|
||||
(`project_id` denormalised onto every row, with `ON DELETE CASCADE` from
|
||||
`projects`). `UNIQUE (project_id, devices.name)` — no two devices in
|
||||
one project share a name.
|
||||
- Every device, port, cable, IO marker, bundle, and **connection
|
||||
requirement** is **project-scoped** (`project_id` denormalised onto
|
||||
every row, with `ON DELETE CASCADE` from `projects`).
|
||||
`UNIQUE (project_id, devices.name)` — no two devices in one project
|
||||
share a name.
|
||||
- **Cable types are global.** A single shared `cable_types` table —
|
||||
no `project_id`. The five defaults (Power/USB/HDMI/DP/RJ45) are seeded
|
||||
by migration 001 once, not per project. Renaming or recolouring a type
|
||||
affects every project's legend immediately.
|
||||
- **Device types are hybrid.** `device_types` is one global table with
|
||||
`project_id` NULL for the 11 built-in catalog rows (seeded by
|
||||
migration 002) and `project_id = current` for project-custom types.
|
||||
Each `device_type` carries a `device_type_ports` profile that seeds
|
||||
`ports` rows when a device of that type is created. m can extend the
|
||||
catalog per project; built-ins are read-only from the API.
|
||||
- **Connection requirements** (`connection_requirements` table) are the
|
||||
solver's input. m declares "from_device ↔ to_device, preferred cable
|
||||
type, must_connect"; the solver assigns ports and emits cables.
|
||||
- **Project deletion guardrail.** `DELETE /api/projects/:pid` requires
|
||||
`?confirm=<name>` matching the project's current name. 400 otherwise.
|
||||
- **Solver-owned vs. user-owned cables.** `cables.auto = 1` = created by
|
||||
the solver and replaceable on re-solve. `auto = 0` = hand-drawn by m,
|
||||
left alone by the solver. PATCHing endpoint or type of an auto cable
|
||||
promotes it to manual (explicit "Promote to manual" button in the
|
||||
inspector, per design v4 §5b.3).
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
@@ -137,12 +174,14 @@ Legend colours (global, seeded once by migration 001):
|
||||
|
||||
## Worker Preferences
|
||||
|
||||
- **First shift = inventor** (design pass): conventions, schema, API,
|
||||
export pipeline, mDock deploy plan, UI flows, slices. Output:
|
||||
`docs/design.md` + open questions for m.
|
||||
- **Second shift = coder** (after m's go on the design): bootstrap repo
|
||||
skeleton (Go module, SQLite migrations, server, exporter, frontend
|
||||
scaffold). Take slices 1–4 first (project CRUD, frames/devices, ports
|
||||
and cables, IO + cable-type editing); slice 5 (Excalidraw export) closes
|
||||
the round-trip.
|
||||
- Use **Sonnet** for both — greenfield, structure matters more than depth.
|
||||
- **Inventor shifts** (design passes): conventions, schema, API, export
|
||||
pipeline, mDock deploy plan, UI flows, slices. Output: `docs/design.md`
|
||||
+ open questions for m. v1–v4 are versioned in the doc's header callout.
|
||||
- **Coder shifts** (after m's go on a design version): build to the
|
||||
current design.md. Current state: slice 1 (project CRUD + global
|
||||
cable_types) and slice 2 (frames + devices + drag) are merged; design
|
||||
v4 reshapes slices 3+ (IO + cable-type editing → device-type catalog →
|
||||
device-type manage → connection-requirements UI → solver → manual
|
||||
port/cable draw → export). See `docs/design.md` §8 for the current
|
||||
sequence.
|
||||
- Use **Sonnet** for both — structure matters more than depth.
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
|
||||
services:
|
||||
mcables:
|
||||
# Pushed under the mAi namespace because the mAi token doesn't have
|
||||
# write permission to the m/* namespace on mgit.msbls.de today. If m
|
||||
# later grants mAi collaborator access on m/mCables, retag to
|
||||
# mgit.msbls.de/m/mcables:latest and align with the other mDock
|
||||
# services (msports-garmin, mgreen-journal, …).
|
||||
image: mgit.msbls.de/mai/mcables:latest
|
||||
image: mgit.msbls.de/m/mcables:latest
|
||||
container_name: mcables
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
787
docs/design.md
787
docs/design.md
@@ -1,36 +1,95 @@
|
||||
# mCables — Design v3
|
||||
# mCables — Design v4.1
|
||||
|
||||
Cable-management **framework** for m's setup. Inventor shift 1 design,
|
||||
revised after m's round-4 answers (2026-05-15) — for m's review.
|
||||
Cable-management **framework + solver** for m's setup. Inventor shift 1
|
||||
design, revised through v2 (rescope to multi-project framework), v3
|
||||
(global cable_types + guardrails), v4 (solver-as-core), and now
|
||||
**v4.1 — six locked answers from m's v4 review**.
|
||||
|
||||
> **What changed in v4.1** (tight pass on v4)
|
||||
> 1. **mCables is a schematic, not a physical-routing tool.** Cables are
|
||||
> straight lines between endpoints; the solver and the renderer do not
|
||||
> care about paths, trunks, frame edges, or cable-tray polylines.
|
||||
> "Maximum bundling" reduces to the v3 rule: **≥2 cables between the
|
||||
> same endpoint pair → bundle them.** All path-routing language has
|
||||
> been stripped from §5b.1, §5b.2, §7, §8, §9.
|
||||
> 2. **Solver fires on the Solve button (v0).** Live-solve stays in §8
|
||||
> slices 9+ as an opt-in toggle.
|
||||
> 3. **Unmet-requirement quick-fix**: when the solver returns
|
||||
> `unsatisfied[]`, the device inspector renders a red badge per unmet
|
||||
> requirement with a single button — **"+ Add <type> port to
|
||||
> <device> and re-solve"** — that POSTs a new port to the
|
||||
> device AND immediately re-runs `POST /api/projects/:pid/solve` in
|
||||
> the same UI action. See §5b.4 + §7 inspector-states.
|
||||
> 4. **Setup templates fold INTO v4.1.** New tables `setup_templates`,
|
||||
> `setup_template_devices`, `setup_template_requirements` in
|
||||
> migration 004 + 3 built-in templates ('Living Room', 'Home Office',
|
||||
> 'Server Rack'). New endpoints `GET /api/setup-templates` and
|
||||
> `POST /api/projects/:pid/apply-template`. UI: a "Templates" panel
|
||||
> in the New Project flow + an "Apply template" action on an empty
|
||||
> project. See new §2.4 + slice 6 fold-in below.
|
||||
> 5. **Catalog distribution: SQL seed** in migration 002 (no change).
|
||||
> 6. **Promote to manual: explicit button** on the cable inspector
|
||||
> (no change).
|
||||
|
||||
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
|
||||
the *visual-grammar reference*, not as a bootstrap import target),
|
||||
`mai-memory` (`mcables`, `m`), and a live survey of mDock services for the
|
||||
deploy conventions (§10).
|
||||
the *visual-grammar reference*, not a bootstrap import target),
|
||||
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy
|
||||
conventions (§10). v4 driven by m's product-vision clarification:
|
||||
|
||||
> **What changed in v3** (mechanical deltas on top of v2)
|
||||
> - `cable_types` is now a **global** table — one set shared across all
|
||||
> projects. Migration 001 seeds the 5 defaults once. `POST /api/projects`
|
||||
> no longer seeds types. API moved to top-level `/api/cable-types`.
|
||||
> Renaming/recolouring a type affects every project.
|
||||
> - `devices` gains `UNIQUE (project_id, name)` — no two devices in the
|
||||
> same project can share a name.
|
||||
> - `projects.drawing_name` is auto-filled `<name>.excalidraw` server-side
|
||||
> when omitted on POST; editable via PATCH.
|
||||
> - `DELETE /api/projects/:pid` requires `?confirm=<name>` query param;
|
||||
> server checks it matches the project's current name. 400 otherwise.
|
||||
> "we provide a cable manager — I say what devices we have, the app tells
|
||||
> me how to bundle cables and how the most efficient connection looks like"
|
||||
|
||||
mCables shifts from a manual draw-and-click editor to a **solver** that
|
||||
takes a list of devices + the connections m needs and emits the cable
|
||||
plan + bundle recommendations. The manual editor stays (it's the only way
|
||||
to inspect + tweak the plan) but is no longer the primary surface.
|
||||
|
||||
> **What changed in v4** (new mental model on top of v3 mechanics)
|
||||
> - **Hybrid device-type catalog** (§2.1, §3.1). A built-in `device_types`
|
||||
> table seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz,
|
||||
> ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port
|
||||
> profiles (`device_type_ports` rows: cable_type + count + label).
|
||||
> Adding a device → pick a type → ports auto-seed. m can override per
|
||||
> instance (this PC has 3 USB, not 2). Catalog is extendable per project.
|
||||
> - **`connection_requirements` table** (§2.2). m declares "NAS must
|
||||
> connect to Switch via RJ45" once. Many per device. The solver consumes
|
||||
> these.
|
||||
> - **`POST /api/projects/:pid/solve` endpoint** (§3.2). Reads devices +
|
||||
> their ports + connection_requirements + frame positions, emits a diff
|
||||
> of `cables` + `bundles`. Two modes: `?preview=1` returns the diff
|
||||
> without applying; default applies.
|
||||
> - **Solver objective: maximum bundling** (§5b.1). Schematic only: when
|
||||
> two or more cables share the same endpoint pair, group them into one
|
||||
> bundle. No path or trunk geometry — mCables is a wiring schematic,
|
||||
> not a routing tool. v4.1 strips all path/trunk language from the v4
|
||||
> draft.
|
||||
> - **UI: device-type dropdown** on device-create, **Connection
|
||||
> Requirements** left panel, **Solve** button next to Export. Inspector
|
||||
> shows type + ports + unmet requirements (selected device) or the
|
||||
> driving requirement + bundle (selected cable).
|
||||
> - **Slices reshape** (§8). Catalog seeding lands early (slice 1.5); the
|
||||
> solver MVP and connection-requirements UI move ahead of the
|
||||
> bundle-rendering polish.
|
||||
>
|
||||
> **What carried over from v2**
|
||||
> - mCables is a framework: top-level `projects` table; LOFT and OFFICE
|
||||
> are separate projects, each backed by one drawing.
|
||||
> - No runtime importer. The seed drawing is reference material only.
|
||||
> `/api/sync/import` is out of MVP; only `POST .../sync/export` ships.
|
||||
> - IO diamonds are wall-outlet terminators (type=Power by convention,
|
||||
> not enforced in schema). UI soft-warns on non-Power cables to an IO.
|
||||
> - No cable inventory metadata. Purely visual structure for v0.
|
||||
> - DB at `./data/mcables.db` (project-local, gitignored).
|
||||
> - Deploy: raw docker / docker-compose on mDock (not Dokploy).
|
||||
> - Bind `0.0.0.0:7777` on the LAN, no auth.
|
||||
> **What carried over from v3 (unchanged in v4)**
|
||||
> - mCables is a framework: top-level `projects`, each backed by one
|
||||
> `.excalidraw` drawing. `UNIQUE(projects.name)`.
|
||||
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
|
||||
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
|
||||
> tri-state on PATCH.
|
||||
> - IO diamonds = wall-outlet terminators (type=Power by convention).
|
||||
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
|
||||
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
|
||||
> - No cable inventory metadata; visual + connectivity structure only.
|
||||
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
|
||||
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose.
|
||||
>
|
||||
> **What's superseded in v4**
|
||||
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
|
||||
> tweak path on the solver output, but is no longer the *primary* device-
|
||||
> connecting flow. The solve button is the headline action.
|
||||
> - The v3 §8 slice order changes — catalog + types-driven devices + solver
|
||||
> come earlier; the manual-draw-cable slice slides later. See new §8.
|
||||
|
||||
---
|
||||
|
||||
@@ -134,6 +193,49 @@ CREATE TABLE cable_types (
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- v4 — device-type catalog. Seeded built-in types live globally (so
|
||||
-- multiple projects share the "NAS" definition without duplication).
|
||||
-- Per-project custom types are also allowed (project_id non-null for those).
|
||||
-- Renaming a built-in type doesn't propagate retroactively to existing
|
||||
-- devices that already had their ports seeded — they own their port set
|
||||
-- from the moment they were created.
|
||||
CREATE TABLE device_types (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||
-- NULL = built-in (shared), non-null = project-custom
|
||||
name TEXT NOT NULL, -- "NAS", "PC", "TV", "Switch", "IOx-8", "Custom-Foo"
|
||||
kind TEXT NOT NULL DEFAULT 'generic',
|
||||
-- coarse category for UI grouping: 'storage', 'compute',
|
||||
-- 'display', 'audio', 'network', 'hub', 'accessory',
|
||||
-- 'generic'
|
||||
icon TEXT, -- emoji or short symbol (🖥, 📺, 🔊, 📡) — UI hint
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
built_in INTEGER NOT NULL DEFAULT 0, -- 1 for migration-seeded rows, 0 for user-created
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, name) -- two projects can both have a custom "Foo";
|
||||
-- built-ins (project_id NULL) get UNIQUE on name globally
|
||||
);
|
||||
CREATE INDEX device_types_project_idx ON device_types(project_id);
|
||||
|
||||
-- v4 — port profile per device type. "NAS has 1 Power + 1 RJ45" is two
|
||||
-- rows; "PC has 1 Power + 1 RJ45 + 1 HDMI + 2 USB" is four rows.
|
||||
-- When a device is created with type_id=X, the seeder inserts `count`
|
||||
-- rows into the `ports` table for each device_type_ports entry,
|
||||
-- numbering label as "<label_prefix> N" if count > 1.
|
||||
CREATE TABLE device_type_ports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE CASCADE,
|
||||
cable_type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
|
||||
label_prefix TEXT NOT NULL DEFAULT '', -- "HDMI", "USB", "Power" — UI label root
|
||||
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
|
||||
-- Position hint: the seeder lays ports along the device edge using
|
||||
-- these biases (0..1 along the edge fraction). NULL = even spread.
|
||||
edge TEXT NOT NULL DEFAULT 'bottom' CHECK (edge IN ('top','bottom','left','right')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX device_type_ports_type_idx ON device_type_ports(device_type_id);
|
||||
|
||||
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
|
||||
CREATE TABLE frames (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -154,10 +256,19 @@ CREATE INDEX frames_project_idx ON frames(project_id);
|
||||
-- Devices live in a frame (and transitively in a project).
|
||||
-- Stored project_id is denormalised for cheap project-scoped queries; FK
|
||||
-- to frame_id is the structural truth. Both are kept consistent in code.
|
||||
--
|
||||
-- v4 — type_id (nullable) lets a device inherit its port profile from
|
||||
-- a `device_types` row. Once ports are seeded the device "owns" them;
|
||||
-- changing/clearing type_id later does not retroactively re-seed (m's
|
||||
-- per-instance overrides survive). Custom freeform devices (no template)
|
||||
-- keep type_id NULL — that's the v3 "just a rectangle" device.
|
||||
CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
||||
type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL,
|
||||
-- v4: nullable; SET NULL on type delete so we don't
|
||||
-- cascade-delete a device the user still wants
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#1e1e1e',
|
||||
x REAL NOT NULL,
|
||||
@@ -172,6 +283,7 @@ CREATE TABLE devices (
|
||||
);
|
||||
CREATE INDEX devices_project_idx ON devices(project_id);
|
||||
CREATE INDEX devices_frame_idx ON devices(frame_id);
|
||||
CREATE INDEX devices_type_idx ON devices(type_id);
|
||||
|
||||
-- Ports belong to a device. x_offset/y_offset are relative to the device's
|
||||
-- top-left so ports follow when the device moves. project_id denormalised.
|
||||
@@ -260,8 +372,211 @@ CREATE TABLE bundle_cables (
|
||||
PRIMARY KEY (bundle_id, cable_id)
|
||||
);
|
||||
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
|
||||
|
||||
-- v4 — connection_requirements: the input m gives the solver.
|
||||
-- "NAS must connect to Switch via RJ45" is one row. Many per device.
|
||||
--
|
||||
-- preferred_cable_type_id is the cable type m intends — the solver
|
||||
-- needs it to match port colours. NULL means "solver picks" (the solver
|
||||
-- will pick the unique cable_type that is compatible with both ends'
|
||||
-- available port types; if ambiguous it surfaces an error for m).
|
||||
--
|
||||
-- must_connect = 1 (default) means the solver MUST satisfy this; an
|
||||
-- unsatisfiable must_connect surfaces as a hard error in the solve
|
||||
-- result. must_connect = 0 = "nice to have, drop if you run out of
|
||||
-- ports". Used for templates that over-spec.
|
||||
--
|
||||
-- The (from_device_id, to_device_id) pair is normalised on insert so
|
||||
-- (A,B) and (B,A) are the same requirement — UNIQUE on the unordered
|
||||
-- pair + cable type prevents duplicates.
|
||||
CREATE TABLE connection_requirements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||||
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
-- Order-normalised pair: lo = MIN(from, to), hi = MAX(from, to). Set
|
||||
-- in code on insert; the UNIQUE then prevents (A,B,Power) AND
|
||||
-- (B,A,Power) from coexisting. Stored alongside the m-facing
|
||||
-- from/to so the UI doesn't have to denormalise.
|
||||
pair_lo INTEGER NOT NULL,
|
||||
pair_hi INTEGER NOT NULL,
|
||||
CHECK (from_device_id != to_device_id),
|
||||
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
|
||||
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
|
||||
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
|
||||
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
|
||||
```
|
||||
|
||||
### 2.1 Migration sequence
|
||||
|
||||
- **001_init.sql** (v3) — projects, frames, devices (no type_id), ports,
|
||||
cable_types (5 seeded), io_markers, cables, bundles, bundle_cables.
|
||||
- **002_device_catalog.sql** (v4) — `device_types` +
|
||||
`device_type_ports`. Seeds the built-in catalog (§2.2). Adds
|
||||
`devices.type_id` (`ALTER TABLE devices ADD COLUMN type_id INTEGER
|
||||
REFERENCES device_types(id) ON DELETE SET NULL`) and the matching
|
||||
index.
|
||||
- **003_connection_requirements.sql** (v4) — `connection_requirements`.
|
||||
Also adds `cables.auto` (`ALTER TABLE cables ADD COLUMN auto INTEGER
|
||||
NOT NULL DEFAULT 0`) so the solver can distinguish its rows from
|
||||
m's hand-drawn ones (§5b.3).
|
||||
- **004_setup_templates.sql** (v4.1 NEW) — `setup_templates` +
|
||||
`setup_template_devices` + `setup_template_requirements`. Seeds 3
|
||||
built-in templates ('Living Room', 'Home Office', 'Server Rack').
|
||||
|
||||
Slices 1 and 2 already shipped 001. Slice 4 lands 002; slice 5 lands
|
||||
003; slice 6 lands 004 alongside the solver MVP + templates UI.
|
||||
|
||||
### 2.2 Built-in catalog seed (002 INSERTs)
|
||||
|
||||
The 14 built-in types m's setup uses today, with their default port
|
||||
profiles. Stored as `(project_id NULL, built_in 1)`. v4.1 added the
|
||||
three peripheral types (Screen, Keyboard, Mouse) to support the Home
|
||||
Office setup template:
|
||||
|
||||
| `device_types.name` | `kind` | Default ports (cable_type × count) |
|
||||
|---|---|---|
|
||||
| NAS | storage | Power × 1; RJ45 × 1 |
|
||||
| PC | compute | Power × 1; RJ45 × 1; HDMI × 1; USB × 2 |
|
||||
| Mac | compute | Power × 1; HDMI × 1; USB × 2 |
|
||||
| Notebook | compute | Power × 1; USB × 2 |
|
||||
| TV | display | Power × 1; HDMI × 2 |
|
||||
| Soundbar | audio | Power × 1; HDMI × 1 |
|
||||
| Switch | network | Power × 1; RJ45 × 5 |
|
||||
| fritz | network | Power × 1; RJ45 × 4 |
|
||||
| ChromeCast | display | Power × 1; HDMI × 1 |
|
||||
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
|
||||
| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) |
|
||||
| IOx-6 | hub | Power × 1; USB × 6 |
|
||||
| IOx-8 | hub | Power × 1; USB × 8 |
|
||||
| **Screen** | display | Power × 1; HDMI × 1 |
|
||||
| **Keyboard** | accessory | USB × 1 |
|
||||
| **Mouse** | accessory | USB × 1 |
|
||||
|
||||
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing
|
||||
shows them in red because most carry Power, but they also hub USB). v0
|
||||
seeds them as USB hubs; m overrides per-instance. The catalog is editable
|
||||
in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3
|
||||
profile once and not re-override every instance.
|
||||
|
||||
m can also add **project-custom types** at any time (UI: "+ New device
|
||||
type" inside the device-create modal) with `project_id = current`.
|
||||
|
||||
### 2.3 Why ports are still instance-owned
|
||||
|
||||
When m picks a type to create a device, the seeder calls `count` × INSERT
|
||||
into `ports`. From that moment on, ports are instance-level rows owned by
|
||||
that device. Deleting a port from this PC doesn't touch other PCs;
|
||||
changing a type's port profile (in slice 4.5) doesn't retroactively
|
||||
re-seed already-created devices — it only affects subsequent device
|
||||
creations.
|
||||
|
||||
Trade-off acknowledged: m may want a "re-seed from type" action later
|
||||
(slice 5+) to wipe + reset a device's ports. Out of v0 scope; not
|
||||
blocked by the schema.
|
||||
|
||||
### 2.4 Setup templates (v4.1 NEW)
|
||||
|
||||
A setup template is a named recipe of "device-types to add + connection
|
||||
requirements between them" that bootstraps a project from blank to
|
||||
solver-ready in one click. m's three archetypes:
|
||||
|
||||
| Template name | Devices | Default requirements |
|
||||
|---|---|---|
|
||||
| **Living Room** | TV, Soundbar, ChromeCast | TV ↔ Soundbar (HDMI, must); TV ↔ ChromeCast (HDMI, must) |
|
||||
| **Home Office** | PC, Screen, Keyboard, Mouse | PC ↔ Screen (HDMI, must); PC ↔ Keyboard (USB, must); PC ↔ Mouse (USB, must) |
|
||||
| **Server Rack** | NAS, Switch, fritz | NAS ↔ Switch (RJ45, must); Switch ↔ fritz (RJ45, must); fritz ↔ NAS (Power, nice) |
|
||||
|
||||
> "Screen", "Keyboard", "Mouse" are added to the v4 built-in catalog
|
||||
> alongside the existing 11 (Screen: Power × 1 + HDMI × 1; Keyboard: USB × 1;
|
||||
> Mouse: USB × 1). Migration 002 grows to seed 14 built-ins.
|
||||
|
||||
Schema (`004_setup_templates.sql`):
|
||||
|
||||
```sql
|
||||
-- A named recipe: a list of device types + requirements between them.
|
||||
CREATE TABLE setup_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
built_in INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- The devices a template stamps into a project. suggested_name is
|
||||
-- pre-filled into the apply-template form; m can override.
|
||||
CREATE TABLE setup_template_devices (
|
||||
id INTEGER PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE RESTRICT,
|
||||
suggested_name TEXT, -- "TV", "Bedroom TV", "Mac (work)"
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
|
||||
|
||||
-- Requirements between devices in the template, addressed by
|
||||
-- `setup_template_devices.id` (not the runtime device id — they're
|
||||
-- resolved at apply time).
|
||||
CREATE TABLE setup_template_requirements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||
from_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||
to_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||||
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||||
CHECK (from_template_device_id != to_template_device_id)
|
||||
);
|
||||
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);
|
||||
```
|
||||
|
||||
API:
|
||||
|
||||
```
|
||||
GET /api/setup-templates → [SetupTemplate {id, name, description, built_in,
|
||||
devices: [{id, device_type_id,
|
||||
device_type: {…},
|
||||
suggested_name, sort_order}],
|
||||
requirements: [{id, from_template_device_id,
|
||||
to_template_device_id,
|
||||
preferred_cable_type_id,
|
||||
must_connect}]}, …]
|
||||
Read-only; built-ins are not editable via API in v4.1.
|
||||
|
||||
POST /api/projects/:pid/apply-template ← {
|
||||
template_id: <int>,
|
||||
name_overrides: { <template_device_id>: "<name>", … },
|
||||
skip_devices: [<template_device_id>, …] # optional
|
||||
}
|
||||
→ {
|
||||
devices_added: [Device, …],
|
||||
requirements_added: [ConnectionRequirement, …],
|
||||
skipped_devices: [{template_device_id, reason}, …]
|
||||
}
|
||||
Idempotency:
|
||||
- A name collision with an existing device in the
|
||||
project skips that template device (reason = "name
|
||||
already in use"). Caller can pass `name_overrides`
|
||||
to resolve.
|
||||
- Requirements whose endpoints both resolve fire;
|
||||
any whose endpoint was skipped are themselves
|
||||
skipped (logged in `requirements_skipped[]` — same
|
||||
shape).
|
||||
The whole call runs in a single transaction.
|
||||
```
|
||||
|
||||
The seed migration creates the 3 built-ins + their template_devices and
|
||||
template_requirements rows referencing the 14 built-in `device_types` and
|
||||
the 5 built-in `cable_types`. No project_id anywhere — templates are
|
||||
global.
|
||||
|
||||
**FK shape — why `project_id` on every project-scoped row, not just transitively:**
|
||||
|
||||
The structural truth is `cable → port → device → frame → project`. But
|
||||
@@ -328,8 +643,11 @@ PATCH /api/projects/:pid/frames/:id
|
||||
DELETE /api/projects/:pid/frames/:id
|
||||
|
||||
GET /api/projects/:pid/devices
|
||||
POST /api/projects/:pid/devices ← {name, frame_id?, x, y, width, height, color?}
|
||||
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag)
|
||||
POST /api/projects/:pid/devices ← {name, type_id?, frame_id?, x, y, width, height, color?}
|
||||
v4: type_id (optional) seeds ports from the catalog;
|
||||
without it, a freeform device (no ports) is created.
|
||||
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag). type_id can be set or cleared;
|
||||
clearing does NOT delete existing ports (instance-owned).
|
||||
DELETE /api/projects/:pid/devices/:id
|
||||
|
||||
GET /api/projects/:pid/devices/:id/ports
|
||||
@@ -354,12 +672,90 @@ GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …]
|
||||
PATCH /api/projects/:pid/bundles/:id
|
||||
DELETE /api/projects/:pid/bundles/:id
|
||||
|
||||
# v4 — Device-type catalog (mostly global, project-scoped writes for custom rows)
|
||||
GET /api/device-types → built-in catalog (project_id NULL) — read-only listing
|
||||
GET /api/projects/:pid/device-types → built-ins + this project's custom types, merged
|
||||
POST /api/projects/:pid/device-types ← {name, kind?, icon?, description?, ports: [{cable_type_id, count, label_prefix?, edge?}]}
|
||||
Creates a project-custom row (built_in=0); inserts
|
||||
device_type_ports rows in the same transaction.
|
||||
PATCH /api/projects/:pid/device-types/:id ← partial. Only project-custom types are PATCHable;
|
||||
mutating a built-in row → 403 (UI hides edit affordance).
|
||||
Editing ports replaces the device_type_ports rows;
|
||||
existing devices' ports are NOT retroactively reseeded.
|
||||
DELETE /api/projects/:pid/device-types/:id Only project-custom; built-ins → 403.
|
||||
ON DELETE SET NULL on devices.type_id so devices
|
||||
keep their already-seeded ports.
|
||||
|
||||
# v4 — Connection requirements (the solver's input)
|
||||
GET /api/projects/:pid/connection-requirements → [ConnectionRequirement, …]
|
||||
POST /api/projects/:pid/connection-requirements ← {from_device_id, to_device_id,
|
||||
preferred_cable_type_id?, must_connect?, notes?}
|
||||
Server normalises (from, to) into (pair_lo, pair_hi)
|
||||
before insert; duplicate (project, pair_lo, pair_hi,
|
||||
preferred_cable_type_id) → 409 conflict.
|
||||
PATCH /api/projects/:pid/connection-requirements/:id
|
||||
DELETE /api/projects/:pid/connection-requirements/:id
|
||||
|
||||
# v4 — Solver
|
||||
POST /api/projects/:pid/solve ← {} (or {?preview=1} to compute without applying)
|
||||
→ {
|
||||
cables_added: [Cable, …],
|
||||
cables_kept: [int, …], # ids preserved by the diff
|
||||
cables_removed: [int, …], # ids deleted (auto cables only)
|
||||
bundles_added: [{Bundle, cable_ids: [int]}, …],
|
||||
bundles_removed: [int, …],
|
||||
unsatisfied: [{requirement_id, reason}, …],
|
||||
warnings: [string, …],
|
||||
}
|
||||
Default applies in a single transaction. ?preview=1
|
||||
returns the same shape without writing. User-created
|
||||
cables (auto=0 in the cables table; see §5.1) are
|
||||
never touched — the solver only adds/removes its own.
|
||||
|
||||
# v4 — Solver quick-fix combo endpoint (powers the inspector's
|
||||
# "+ Add <type> port to <device> and re-solve" button — §5b.4).
|
||||
POST /api/projects/:pid/devices/:id/ports-and-resolve
|
||||
← {type_id: <int>,
|
||||
label?: <str>,
|
||||
x_offset?: <num>, y_offset?: <num>}
|
||||
→ {port: Port, solve: <solve response>}
|
||||
Single tx: inserts the port + re-runs solve. Used by
|
||||
the quick-fix UI so the unmet badge resolves in one
|
||||
server round-trip.
|
||||
|
||||
# v4.1 — Setup templates
|
||||
GET /api/setup-templates → [SetupTemplate, …]
|
||||
Read-only listing of built-in (and any project-custom,
|
||||
post-v4.1) templates with their device/requirement
|
||||
shapes (see §2.4).
|
||||
POST /api/projects/:pid/apply-template ← {template_id: <int>,
|
||||
name_overrides?: { <template_device_id>: "<name>" },
|
||||
skip_devices?: [<template_device_id>, …]}
|
||||
→ {devices_added: [Device, …],
|
||||
requirements_added: [ConnectionRequirement, …],
|
||||
skipped_devices: [{template_device_id, reason}, …],
|
||||
requirements_skipped: [{template_requirement_id, reason}, …]}
|
||||
Idempotent in spirit: name collisions surface in
|
||||
skipped_devices; m resolves with name_overrides on
|
||||
re-apply. Whole call is one transaction.
|
||||
|
||||
# Sync — export only in MVP
|
||||
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
|
||||
(overwrites previous version; mExDraw keeps
|
||||
git-version-history sidecar)
|
||||
```
|
||||
|
||||
### 3.1 v4 wire-shape additions
|
||||
|
||||
- `ConnectionRequirement` (response):
|
||||
`{id, project_id, from_device_id, to_device_id, preferred_cable_type_id|null, must_connect: bool, notes, created_at, updated_at}`.
|
||||
- `DeviceType` (response):
|
||||
`{id, project_id|null, name, kind, icon|null, description, built_in: bool, ports: [{cable_type_id, count, label_prefix, edge, sort_order}]}`.
|
||||
- `cables` gets an `auto: bool` field on the row (slice 5.5 migration adds
|
||||
the column with default 0; the solver sets 1 on its own creations). The
|
||||
v3 cable rows m hand-drew keep `auto=0`. `POST /api/.../cables`
|
||||
continues to default `auto=0`; only the solver writes `auto=1`.
|
||||
|
||||
No `POST /api/sync/import` in MVP. Import is post-MVP and only ever serves
|
||||
a one-shot migration use case (e.g. seeding LOFT from the legacy
|
||||
Cable-Management drawing if m later changes his mind).
|
||||
@@ -447,6 +843,139 @@ they're ignored in v0 (open question §9).
|
||||
|
||||
---
|
||||
|
||||
## 5b. v4 — Solver
|
||||
|
||||
The solver is the headline addition in v4. m's product-vision sentence
|
||||
maps onto it directly:
|
||||
|
||||
> "I say what devices we have, the app tells me how to bundle cables and
|
||||
> how the most efficient connection looks like"
|
||||
|
||||
The solver reads a project's `devices` (with their `ports`) and
|
||||
`connection_requirements`, and writes a set of solver-owned `cables`
|
||||
(rows with `auto=1`) + `bundles`. m's hand-drawn cables (`auto=0`) are
|
||||
left strictly alone — the solver only adds and removes its own.
|
||||
|
||||
### 5b.1 Objective: maximum bundling — schematic only
|
||||
|
||||
mCables is a **schematic**, not a physical-routing tool. Cables are
|
||||
straight lines between endpoints; the solver has no model of walls,
|
||||
floors, cable trays, or path geometry. "Maximum bundling" therefore
|
||||
reduces to a single rule on the schematic:
|
||||
|
||||
> When two or more cables share the same endpoint pair (device A ↔
|
||||
> device B), group them into one bundle.
|
||||
|
||||
This is the v3 endpoint-pair rule, applied to the solver's output. m's
|
||||
"visually cleaner setups" benefit comes from the bundle being a single
|
||||
labelled set in the inspector + a single mixed-colour glyph in the
|
||||
render (slice 9+), rather than from any path optimisation. Anything
|
||||
about trunks, frame-edge corridors, or auto-routing is out of scope —
|
||||
filed for "post-v0 ambient" in §8.
|
||||
|
||||
### 5b.2 Algorithm (v0)
|
||||
|
||||
Pure function. No graph search; no LP; no path optimisation. Single
|
||||
pass with greedy port allocation.
|
||||
|
||||
```
|
||||
solve(project) ⇒ {add, remove, bundles, unsatisfied}:
|
||||
let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1
|
||||
let port_free := {port_id -> bool} initialised TRUE for every port
|
||||
minus ports already used by manual cables (auto=0)
|
||||
|
||||
for each requirement r in order(must_connect DESC, id ASC):
|
||||
let ct = r.preferred_cable_type_id
|
||||
?? auto_pick_cable_type(r.from_device, r.to_device)
|
||||
?? fail("ambiguous")
|
||||
let pa = first_free_port(r.from_device, ct, port_free)
|
||||
let pb = first_free_port(r.to_device, ct, port_free)
|
||||
if !pa or !pb:
|
||||
if r.must_connect: unsatisfied.push({r.id, reason})
|
||||
else: skip
|
||||
continue
|
||||
port_free[pa] = port_free[pb] = false
|
||||
add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1})
|
||||
|
||||
// Bundle by endpoint-pair (v3 rule, applied only to auto cables).
|
||||
for each (device_a, device_b) pair with ≥ 2 add-cables:
|
||||
bundles_add.push({auto=1, cables: those add-cables})
|
||||
|
||||
// Diff against auto_cables_before to compute remove[] (any prior auto
|
||||
// cable whose (from, to, type) doesn't appear in add[]).
|
||||
remove = auto_cables_before - add
|
||||
return {add, remove, bundles_add, unsatisfied}
|
||||
```
|
||||
|
||||
`first_free_port(device, cable_type, free_map)` picks the lowest-id port
|
||||
on the device whose `type_id` matches and that is still free, returning
|
||||
NULL if none. The `lowest-id` tiebreak is deterministic so repeated
|
||||
solves produce the same plan.
|
||||
|
||||
`auto_pick_cable_type(from, to)` (used when `preferred_cable_type_id` is
|
||||
NULL): find the set of cable types `T = ports(from).types ∩
|
||||
ports(to).types`. If `|T| == 1`, return it. If `|T| > 1`, fail
|
||||
("ambiguous; specify preferred_cable_type_id"). The UI surfaces this
|
||||
as a "specify type" inline edit on the requirement.
|
||||
|
||||
### 5b.3 Solver-owned vs. user-owned cables
|
||||
|
||||
`cables.auto` distinguishes them.
|
||||
|
||||
| Operation | Effect on `auto=0` cables | Effect on `auto=1` cables |
|
||||
|---|---|---|
|
||||
| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a |
|
||||
| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) |
|
||||
| DELETE cables | applies | applies |
|
||||
| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) |
|
||||
|
||||
This way a manual cable m doesn't want the solver to second-guess
|
||||
survives every solve. If m wants the solver to take it over, he deletes
|
||||
his hand-drawn cable and re-solves; the solver re-creates an equivalent
|
||||
auto cable.
|
||||
|
||||
### 5b.4 When solver fails — quick-fix UX
|
||||
|
||||
Three classes of failure surface in the response's `unsatisfied[]`:
|
||||
|
||||
1. **No compatible cable type** — `T = ports(from).types ∩
|
||||
ports(to).types` is empty (e.g. a Power-only device to an HDMI-only
|
||||
device).
|
||||
2. **Ambiguous cable type** — `|T| > 1`, no preferred set on the
|
||||
requirement.
|
||||
3. **No free port** — the cable type matches but every port on one side
|
||||
is already used.
|
||||
|
||||
The solver does **not** auto-add ports without m's consent. v4.1 ships
|
||||
an explicit one-click quick-fix per class of failure, surfaced as a red
|
||||
badge on the affected device in the inspector (§7) and as a button on
|
||||
each `unsatisfied[]` entry in the preview-diff modal:
|
||||
|
||||
| Failure class | Quick-fix button | What it does |
|
||||
|---|---|---|
|
||||
| No compatible cable type | **"+ Add <preferred_type> port to <device> and re-solve"** | POST `/api/projects/:pid/devices/:id/ports` with `type_id=preferred_type` + sensible default offset, then immediately POST `/solve` again. The preferred_type is the requirement's `preferred_cable_type_id`. If the requirement has no preferred type, the button reads "Specify cable type" and opens an inline cable-type picker on the requirement instead. |
|
||||
| Ambiguous cable type | **"Specify cable type"** | Opens an inline picker on the requirement row with the candidates from `T` pre-listed. On select → PATCH the requirement → re-solve. |
|
||||
| No free port | **"+ Add <type> port to <device> and re-solve"** | Same as the no-compat case but the `type` is already determined (it's the requirement's preferred or auto-picked type). Adds a port on whichever side ran out (the response's `reason` carries `which_side`). |
|
||||
|
||||
All three quick-fixes do their work in a single round-trip request from
|
||||
the UI perspective: the click fires a POST that either chains the port
|
||||
insert + the re-solve server-side, or fires both calls back-to-back from
|
||||
the client (server-side chaining is simpler — see §3.2 for the endpoint
|
||||
shape).
|
||||
|
||||
The quick-fix never adds a port silently; the button text always names
|
||||
the device + cable type so m sees what's about to mutate.
|
||||
|
||||
### 5b.5 Preview vs. apply
|
||||
|
||||
`?preview=1` returns the same shape without writing. The UI shows a diff
|
||||
modal with `add[]`, `remove[]`, `unsatisfied[]`; m clicks Apply to fire
|
||||
the same endpoint without `preview=1`. Default (no flag) applies
|
||||
immediately. Live-solve (no Solve button — every requirement edit
|
||||
triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sync — export-only for v0
|
||||
|
||||
```
|
||||
@@ -522,16 +1051,54 @@ the new project (which has 5 seeded cable types and no frames yet).
|
||||
The currently active project's id is kept in URL state
|
||||
(`/?project=LOFT`) so reload returns to the same project.
|
||||
|
||||
### v4.1 — Flow: apply a setup template
|
||||
|
||||
The New Project modal gains a **"or start from a template"** section
|
||||
under the description field. Each built-in template ('Living Room',
|
||||
'Home Office', 'Server Rack') is a clickable card listing its devices +
|
||||
the requirement edges between them. Selecting one expands an inline
|
||||
override form:
|
||||
|
||||
- A pre-filled name for each template device (m can edit each, e.g.
|
||||
rename `TV` to `Bedroom TV`).
|
||||
- Per-device "skip" checkbox.
|
||||
|
||||
On Create, the server does `POST /api/projects` first; on success,
|
||||
immediately fires `POST /api/projects/:pid/apply-template` with the
|
||||
collected overrides. The response's `devices_added` + `requirements_added`
|
||||
are merged into the local snapshot and the project switches to it,
|
||||
already populated.
|
||||
|
||||
For an already-existing empty project, the inspector's project header
|
||||
shows an **"Apply template"** action that opens the same override form
|
||||
without the project-create round-trip.
|
||||
|
||||
Once the template has stamped its devices + requirements, hit **Solve**
|
||||
(§7 "Flow: run the solver") to produce the wired diagram.
|
||||
|
||||
### Flow: add a frame
|
||||
|
||||
1. `+ Frm` in the left toolbar (or `F`).
|
||||
2. Click + drag on the canvas → rubber-band rectangle becomes a frame.
|
||||
3. Name prompt centered in the frame; Enter → `POST .../frames`.
|
||||
|
||||
### Flow: add a device
|
||||
### Flow: add a device (v4 — type-aware)
|
||||
|
||||
Unchanged from v1: `+ Dev` (or `D`) → click on canvas → rectangle placed
|
||||
(falls into whichever frame it lands in) → name → `POST .../devices`.
|
||||
1. `+ Dev` (or `D`) → click on canvas → device placeholder appears.
|
||||
2. **First field in the inline namer: type dropdown** (replaces the
|
||||
v1 plain-name input). Options pulled from
|
||||
`GET /api/projects/:pid/device-types` — built-ins listed first
|
||||
grouped by `kind`, then project-custom rows, then `Custom (no type)`.
|
||||
Typing in the dropdown filters by `name` (m types "n" → NAS jumps
|
||||
to top). Below the dropdown: a name input pre-filled with the type
|
||||
name + a digit if a same-named device already exists ("PC", "PC-2").
|
||||
3. Hit Enter → `POST .../devices` with `type_id` + name. The server
|
||||
seeds the ports from `device_type_ports` in the same transaction
|
||||
and returns the device with its `ports`.
|
||||
4. Picking `Custom (no type)` keeps the v3 behaviour: rectangle, no
|
||||
ports, m adds ports manually via the inspector.
|
||||
5. The device renders with its ports already visible along the
|
||||
configured edge.
|
||||
|
||||
### Flow: add a port
|
||||
|
||||
@@ -581,54 +1148,140 @@ In the inspector with nothing else selected, "Bundle suggestions" pulls
|
||||
on the diagram + an Accept button. Manual: shift-click multiple cables →
|
||||
"Group as bundle" → name it → save.
|
||||
|
||||
### v4 — Flow: declare connection requirements
|
||||
|
||||
The left sidebar gains a **Requirements** section under the legend:
|
||||
|
||||
```
|
||||
Cable types
|
||||
Power, USB, HDMI, DP, RJ45, + Type
|
||||
|
||||
Requirements ← new in v4
|
||||
NAS ↔ Switch RJ45 must
|
||||
PC ↔ TV HDMI must
|
||||
Mac ↔ Soundbar HDMI nice
|
||||
+ Requirement
|
||||
```
|
||||
|
||||
Click `+ Requirement` → modal with two device pickers (autocomplete from
|
||||
the project's current devices), a cable-type picker (defaults to
|
||||
auto-resolve if the device pair has only one matching type), and a
|
||||
must/nice toggle. `POST .../connection-requirements`.
|
||||
|
||||
Alternative gesture (no tool armed, no selection): **drag from device A
|
||||
to device B** to seed a requirement modal with the pair pre-filled. The
|
||||
solver-edge preview drags out from the source device's edge in a thin
|
||||
dashed line until release.
|
||||
|
||||
m can also right-click a requirement row → edit / delete.
|
||||
|
||||
### v4 — Flow: run the solver
|
||||
|
||||
Header gains a **Solve** button next to **Export**.
|
||||
|
||||
1. Click Solve (or `S`) → `POST /api/projects/:pid/solve?preview=1`.
|
||||
2. A diff modal opens listing `add[]`, `remove[]`, `unsatisfied[]` — the
|
||||
canvas behind it dims and previews the new cables in a translucent
|
||||
stroke + the to-be-removed cables in a strikethrough red.
|
||||
3. Buttons:
|
||||
- **Apply** → fires `POST .../solve` (no `preview`), applies in one
|
||||
transaction, closes the modal, re-renders canvas with the real
|
||||
cables in place.
|
||||
- **Cancel** → leaves everything as it was.
|
||||
4. Unsatisfied requirements get their own list at the bottom of the
|
||||
modal, each with a quick-action button: "Specify type", "+ Add port
|
||||
to device X", or "Drop requirement (set must=0)".
|
||||
|
||||
If `unsatisfied[]` is non-empty, the Solve button stays in a
|
||||
soft-error state (yellow) until either every requirement is satisfiable
|
||||
or m explicitly accepts the partial plan.
|
||||
|
||||
### v4 — Inspector states
|
||||
|
||||
| Selection | Inspector shows |
|
||||
|---|---|
|
||||
| nothing | empty, with "Bundle suggestions" + "Project requirements" headlines |
|
||||
| project header | name, drawing_name, description (editable), device count, requirement count, Solve / Export buttons |
|
||||
| frame | name (editable), x/y/w/h, contained-device count, delete |
|
||||
| **device** | name + type + icon, ports grid (type / label / connected? / +Port), **unmet requirements list** with red badges. Each badge carries a single quick-fix button — "+ Add <type> port to <device> and re-solve" (no-compat-type / no-free-port cases) or "Specify cable type" (ambiguous case) per §5b.4. delete |
|
||||
| **port** | type, label, parent device, current cable (if any), delete |
|
||||
| **cable (auto=1)** | source/target, type, driving requirement (clickable → opens requirement edit), parent bundle (if any), label, "Promote to manual" (sets auto=0) |
|
||||
| cable (auto=0) | as v3 — type, source/target, label, delete |
|
||||
| bundle | name, member cables (clickable to focus), the endpoint pair (`Device A ↔ Device B`), auto-detected flag |
|
||||
|
||||
### Keyboard
|
||||
|
||||
`P` switch project (opens picker), `F` add frame, `D` add device,
|
||||
`I` add IO marker, `T` start cable from selected port,
|
||||
`E` export current project, `Esc` cancel, `Backspace` delete selection,
|
||||
`?` show shortcuts.
|
||||
`P` switch project, `F` add frame, `D` add device, `I` add IO marker,
|
||||
`T` start cable from selected port, `R` add requirement,
|
||||
**`S` solve project (v4)**, `E` export, `Esc` cancel, `Backspace` delete
|
||||
selection, `?` show shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## 8. First slices
|
||||
## 8. First slices — v4 reshape
|
||||
|
||||
Each slice ends with something m can click. The first coder shift takes
|
||||
slices 1–4 as the MVP; slice 5 (export) is the round-trip end.
|
||||
Slices 1 + 2 have shipped (see git history). v4 inserts new slices ahead
|
||||
of the original 3-5 because the solver depends on the catalog + the
|
||||
requirements model, not on manual cable drawing. The old "manual port +
|
||||
cable draw" slice is still in scope as a tweak path on the solver
|
||||
output, but it follows the solver instead of leading.
|
||||
|
||||
| # | Slice | What's shipped |
|
||||
|---|---|---|
|
||||
| 1 | **Bootstrap + project CRUD** | `cmd/mcables` Go binary, SQLite migrations. Migration 001 seeds the 5 default cable types (Power/USB/HDMI/DP/RJ45) **globally, once**. `internal/db` store. `POST /api/projects` auto-fills `drawing_name = <name>.excalidraw` when omitted. `DELETE /api/projects/:pid?confirm=<name>` with name-match guardrail. `GET /api/projects` lists them. `GET /api/projects/:pid` returns a (mostly empty) snapshot. `GET /api/cable-types` returns the 5 seeded rows. Frontend `index.html` + `main.js` shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from the global `cable_types` table. m can create LOFT, see it picked, see no devices. |
|
||||
| 2 | **Add frame, add device, drag-to-position** | `+ Frm` and `+ Dev` tools work. Devices and frames persist. Drag-to-position writes back to DB on `pointerup`. Reload returns to the same layout. m builds LOFT's `desk` and `rack` frames and drops in his first devices. |
|
||||
| 3 | **Add port, draw cable** | `+ Port` (with a device selected) places type-coloured ports on device edges with offsets. Click-port → click-port creates a cable. Cables auto-route as straight lines. Inspector shows the cable's type, endpoints, label. m wires up the first end-to-end cable. |
|
||||
| 4 | **IO markers + cable-type editing** | `+ IO` places a wall-outlet diamond. Cable-from-port → IO commits as `to_io_id`. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new types. m can fully recreate LOFT's visual model from scratch. |
|
||||
| 5 | **Export to mxdrw.msbls.de** | `POST .../sync/export` generates a `.excalidraw` scene that reproduces the seed's visual grammar (ports as positional ellipses, IO as diamonds, legend as text in the top-left), writes it via mExDraw API, and stores the assigned `excalidraw_id`s for stability on re-export. m sees LOFT in Excalidraw and confirms the look matches the seed. |
|
||||
| # | Slice | Status | What's shipped |
|
||||
|---|---|---|---|
|
||||
| 1 | **Bootstrap + project CRUD + global cable_types** | ✅ shipped | See git: branch `mai/picasso/slice-1-bootstrap`. |
|
||||
| 2 | **Frames + devices + drag** | ✅ shipped | See git: branch `mai/picasso/slice-2-frames-devices`. |
|
||||
| **3 (was 4)** | **IO markers + cable-type editing** | pending | Unchanged scope. `+ IO` places a wall-outlet diamond. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new global types. |
|
||||
| **4 (NEW)** | **Device-type catalog + type-aware device create** | pending | Migration 002: `device_types` + `device_type_ports`, seeded with the 11 built-ins (§2.2). Migration adds `devices.type_id`. API: `GET /api/device-types`, `GET /api/projects/:pid/device-types`. Frontend: the +Dev inline namer becomes a type dropdown + name input; choosing a built-in type seeds the device's ports on the backend. Picking `Custom (no type)` falls back to v3 freeform. m can create a typed NAS + see its Power + RJ45 ports appear on the canvas. |
|
||||
| **4.5 (NEW)** | **Manage device-type catalog (per project)** | pending | Modal: `POST/PATCH/DELETE /api/projects/:pid/device-types` for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by `UNIQUE(project_id, name)` + a separate code-level check against built-ins). |
|
||||
| **5 (NEW)** | **Connection requirements UI + CRUD** | pending | Migration 003: `connection_requirements`. API: full CRUD under `/api/projects/:pid/connection-requirements`. Frontend: left-sidebar "Requirements" section, `+ Requirement` modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. |
|
||||
| **6 (v4.1 EXPANDED)** | **Solver MVP + Solve button + setup templates** | pending | `POST /api/projects/:pid/solve` with `?preview=1` support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling. Migration 003 adds `cables.auto`. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan + unmet requirements (each with its quick-fix button per §5b.4) → applies. **Folded in v4.1: setup templates.** Migration 004 adds `setup_templates` + `setup_template_devices` + `setup_template_requirements` and seeds 3 built-ins ('Living Room', 'Home Office', 'Server Rack'). API: `GET /api/setup-templates`, `POST /api/projects/:pid/apply-template`. UI: a "Templates" section in the New Project modal + an "Apply template" action on empty projects → seeds devices + requirements in one transaction → Solve produces the wired diagram. |
|
||||
| **7 (was 3, slimmed)** | **Manual port + manual cable draw** | pending | The v3 flow as a tweak path on solver output. `+ Port` on an instance-owned device; click-port → click-port creates a hand-drawn cable (`auto=0`). Used to override the solver's choices or to extend its plan. |
|
||||
| **8 (was 5)** | **Export to mxdrw.msbls.de** | pending | `POST .../sync/export` writes a `.excalidraw` scene per the visual grammar (§4). Bundles ignored on export in v0. |
|
||||
|
||||
Slices 6+ (not promised for the first coder shift):
|
||||
bundle suggestions UI; bundle rendering (thick path with mixed-colour
|
||||
fan-out); cable type "warn on cross-type port-to-port"; cable inventory
|
||||
metadata (length/SKU) if m later wants it; dark mode.
|
||||
Slices 9+ (not promised for the first coder shift):
|
||||
- Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast. Opt-in toggle in project settings.
|
||||
- Bundle rendering in the SVG (a single thick line with mixed-colour stops between the endpoint pair, plus a small badge with the cable count). Cables in a bundle still render as their individual lines underneath; the bundle is a visual overlay m can toggle.
|
||||
- "Re-seed from type" action on a device.
|
||||
- Custom setup templates (m authors them in-UI, not just the built-in three).
|
||||
- Cable inventory metadata (length/SKU) if m later wants it.
|
||||
- Dark mode.
|
||||
|
||||
Out of scope, period (would change mCables's mental model): path
|
||||
routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
|
||||
3D, anything that treats a cable as more than a labelled endpoint pair.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m — all resolved in v3
|
||||
## 9. Open questions for m — all closed in v4.1
|
||||
|
||||
All six v2 questions are now answered. Locked answers:
|
||||
The six v4 questions are now answered. Locked answers:
|
||||
|
||||
1. **Drawing-name policy** → server-side default `<name>.excalidraw` on
|
||||
POST when omitted; editable via PATCH. (§3)
|
||||
2. **Device-name uniqueness within a project** → `UNIQUE (project_id,
|
||||
devices.name)` enforced at the schema level. (§2)
|
||||
3. **Non-Power IO markers** → no `type_id` on `io_markers` for v0.
|
||||
Power-by-convention; UI soft-warns on non-Power cables to an IO. (§2, §7)
|
||||
4. **Bundle render in export v1** → bundles ignored on export until slice
|
||||
6+. (§4, §5)
|
||||
5. **Cross-project cable types** → `cable_types` is fully **global**. One
|
||||
shared legend; renaming/recolouring affects every project. (§2, §3, §7)
|
||||
6. **Project deletion guardrail** → `DELETE /api/projects/:pid?confirm=<name>`
|
||||
required; server validates name match, returns 400 otherwise. (§3)
|
||||
1. **Where do paths come from?** → **Nowhere — mCables is a schematic.**
|
||||
Cables are straight lines between endpoints. The solver does not
|
||||
route, the renderer does not route, and "maximum bundling" reduces to
|
||||
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
|
||||
cable tray, or frame-edge corridor is **out of scope, period**
|
||||
(§8 "Out of scope, period").
|
||||
2. **Live solve or button-only?** → **Button-only for v0.** Live-solve
|
||||
stays parked at slice 9+ as an opt-in.
|
||||
3. **No-compatible-port-pair UX.** → **Explicit quick-fix.** The
|
||||
unsatisfied-requirement badge in the inspector carries a single
|
||||
button — "+ Add <type> port to <device> and re-solve" —
|
||||
that POSTs the port AND fires `/solve` in one UI action. The button
|
||||
text always names the device + type, so m sees what's about to
|
||||
mutate (§5b.4 + §7).
|
||||
4. **Setup templates.** → **Folded INTO v4.1, in slice 6.** Migration 004
|
||||
adds `setup_templates` + child tables + 3 built-ins. `GET
|
||||
/api/setup-templates` and `POST /api/projects/:pid/apply-template`
|
||||
ship alongside the solver (§2.4 + §3 + slice 6 in §8). Custom
|
||||
templates (m authors his own) parked at slice 9+.
|
||||
5. **Catalog distribution.** → **SQL seed in migration 002.** No
|
||||
external file loader.
|
||||
6. **Promote to manual.** → **Explicit button** on the cable inspector
|
||||
(§7 row "cable (auto=1)"). PATCHes that only update labels stay auto.
|
||||
|
||||
No open design questions remain. The coder shift is gated on m's
|
||||
go/no-go for v3 — not on any unanswered design question from picasso.
|
||||
go/no-go for v4.1 — not on any unanswered design question from picasso.
|
||||
|
||||
---
|
||||
|
||||
@@ -777,4 +1430,4 @@ gitignored.
|
||||
|
||||
---
|
||||
|
||||
DESIGN v3 READY — coder shift gated
|
||||
DESIGN v4.1 READY FOR REVIEW
|
||||
|
||||
397
internal/db/frames_devices.go
Normal file
397
internal/db/frames_devices.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Frames
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// FrameCreate is the create-shape; x/y/width/height carry full positions.
|
||||
type FrameCreate struct {
|
||||
Name string
|
||||
X float64
|
||||
Y float64
|
||||
Width float64
|
||||
Height float64
|
||||
}
|
||||
|
||||
// FrameUpdate is the partial-update shape for PATCH. project_id is
|
||||
// deliberately absent — moving a frame across projects would orphan its
|
||||
// devices' frame_id refs, so the API refuses to do it.
|
||||
type FrameUpdate struct {
|
||||
Name *string
|
||||
X *float64
|
||||
Y *float64
|
||||
Width *float64
|
||||
Height *float64
|
||||
}
|
||||
|
||||
// CreateFrame inserts a new frame inside a project.
|
||||
func (s *Store) CreateFrame(projectID int64, f FrameCreate) (*Frame, error) {
|
||||
name := strings.TrimSpace(f.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if f.Width <= 0 || f.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
||||
}
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO frames (project_id, name, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
projectID, name, f.X, f.Y, f.Width, f.Height,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetFrame(projectID, id)
|
||||
}
|
||||
|
||||
// GetFrame loads a frame, enforcing project_id scoping.
|
||||
func (s *Store) GetFrame(projectID, id int64) (*Frame, error) {
|
||||
var f Frame
|
||||
var ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
|
||||
&ex, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ex.Valid {
|
||||
f.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// ListFrames returns every frame in a project, ordered by created_at so
|
||||
// the on-screen z-order is stable.
|
||||
func (s *Store) ListFrames(projectID int64) ([]Frame, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM frames WHERE project_id = ? ORDER BY created_at, id`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Frame{}
|
||||
for rows.Next() {
|
||||
var f Frame
|
||||
var ex sql.NullString
|
||||
if err := rows.Scan(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
|
||||
&ex, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ex.Valid {
|
||||
f.ExcalidrawID = &ex.String
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateFrame applies a partial update. project_id stays the same — we
|
||||
// don't expose moving a frame across projects.
|
||||
func (s *Store) UpdateFrame(projectID, id int64, u FrameUpdate) (*Frame, error) {
|
||||
cur, err := s.GetFrame(projectID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Name != nil {
|
||||
v := strings.TrimSpace(*u.Name)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
|
||||
}
|
||||
cur.Name = v
|
||||
}
|
||||
if u.X != nil {
|
||||
cur.X = *u.X
|
||||
}
|
||||
if u.Y != nil {
|
||||
cur.Y = *u.Y
|
||||
}
|
||||
if u.Width != nil {
|
||||
if *u.Width <= 0 {
|
||||
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
|
||||
}
|
||||
cur.Width = *u.Width
|
||||
}
|
||||
if u.Height != nil {
|
||||
if *u.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
|
||||
}
|
||||
cur.Height = *u.Height
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE frames
|
||||
SET name = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND project_id = ?`,
|
||||
cur.Name, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetFrame(projectID, id)
|
||||
}
|
||||
|
||||
// DeleteFrame removes a frame. Devices with `frame_id = id` keep existing
|
||||
// — the schema's ON DELETE SET NULL drops their frame_id to NULL so they
|
||||
// stay in the project as "outside a frame".
|
||||
func (s *Store) DeleteFrame(projectID, id int64) error {
|
||||
if _, err := s.GetFrame(projectID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Devices
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// DeviceCreate is the create-shape. FrameID may be nil ("outside any frame").
|
||||
type DeviceCreate struct {
|
||||
Name string
|
||||
FrameID *int64
|
||||
Color string
|
||||
X float64
|
||||
Y float64
|
||||
Width float64
|
||||
Height float64
|
||||
}
|
||||
|
||||
// DeviceUpdate is the partial-update shape. project_id deliberately not
|
||||
// settable. FrameID is *(*int64) so callers can distinguish "leave as-is"
|
||||
// (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the
|
||||
// inner pointer is nil to clear.
|
||||
type DeviceUpdate struct {
|
||||
Name *string
|
||||
FrameID FrameRef // see FrameRef below
|
||||
Color *string
|
||||
X *float64
|
||||
Y *float64
|
||||
Width *float64
|
||||
Height *float64
|
||||
}
|
||||
|
||||
// FrameRef encodes a tri-state for the FrameID PATCH:
|
||||
//
|
||||
// Set=false → leave the field untouched
|
||||
// Set=true, ID=nil → set to NULL (device leaves all frames)
|
||||
// Set=true, ID=&someInt → set to that frame id (must be in same project)
|
||||
type FrameRef struct {
|
||||
Set bool
|
||||
ID *int64
|
||||
}
|
||||
|
||||
// CreateDevice inserts a new device. FrameID, if provided, must reference
|
||||
// a frame in the same project.
|
||||
func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if d.Width <= 0 || d.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
||||
}
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.FrameID != nil {
|
||||
if _, err := s.GetFrame(projectID, *d.FrameID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *d.FrameID, projectID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
color := strings.TrimSpace(d.Color)
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO devices (project_id, frame_id, name, color, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
projectID, nullableInt64(d.FrameID), name, color, d.X, d.Y, d.Width, d.Height,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetDevice(projectID, id)
|
||||
}
|
||||
|
||||
// GetDevice loads a device, project-scoped.
|
||||
func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
|
||||
var d Device
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
&ex, &d.CreatedAt, &d.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
d.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
d.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// ListDevices returns devices in a project. If frameID is non-nil and
|
||||
// dereferences to a value, only devices with that frame_id are returned;
|
||||
// if frameID dereferences to nil (i.e. caller passed &FrameRef{Set:true,ID:nil})
|
||||
// — actually this signature uses *int64 directly: pass nil for "all
|
||||
// devices", or pass &someInt for "devices in that frame". The empty-
|
||||
// "outside-any-frame" filter isn't exposed yet — slice 2 doesn't need it.
|
||||
func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if frameID != nil {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`,
|
||||
projectID, *frameID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE project_id = ? ORDER BY created_at, id`,
|
||||
projectID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Device{}
|
||||
for rows.Next() {
|
||||
var d Device
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
&ex, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
d.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
d.ExcalidrawID = &ex.String
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateDevice applies a partial update. FrameID is tri-state — see FrameRef.
|
||||
// A FrameID set to a non-nil ID must reference a frame in the same project.
|
||||
func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, error) {
|
||||
cur, err := s.GetDevice(projectID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Name != nil {
|
||||
v := strings.TrimSpace(*u.Name)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
|
||||
}
|
||||
cur.Name = v
|
||||
}
|
||||
if u.Color != nil {
|
||||
v := strings.TrimSpace(*u.Color)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("%w: color cannot be empty", ErrInvalidInput)
|
||||
}
|
||||
cur.Color = v
|
||||
}
|
||||
if u.X != nil {
|
||||
cur.X = *u.X
|
||||
}
|
||||
if u.Y != nil {
|
||||
cur.Y = *u.Y
|
||||
}
|
||||
if u.Width != nil {
|
||||
if *u.Width <= 0 {
|
||||
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
|
||||
}
|
||||
cur.Width = *u.Width
|
||||
}
|
||||
if u.Height != nil {
|
||||
if *u.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
|
||||
}
|
||||
cur.Height = *u.Height
|
||||
}
|
||||
if u.FrameID.Set {
|
||||
if u.FrameID.ID != nil {
|
||||
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cur.FrameID = u.FrameID.ID
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE devices
|
||||
SET frame_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND project_id = ?`,
|
||||
nullableInt64(cur.FrameID), cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetDevice(projectID, id)
|
||||
}
|
||||
|
||||
// DeleteDevice removes a device from a project.
|
||||
func (s *Store) DeleteDevice(projectID, id int64) error {
|
||||
if _, err := s.GetDevice(projectID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nullableInt64 converts a *int64 into a sql.NullInt64 so we can pass it
|
||||
// straight into a parameterised query.
|
||||
func nullableInt64(p *int64) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
235
internal/db/frames_devices_test.go
Normal file
235
internal/db/frames_devices_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------- frames
|
||||
|
||||
func TestCreateFrame_Basics(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 10, Y: 20, Width: 800, Height: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if f.ProjectID != p.ID || f.Name != "desk" || f.Width != 800 {
|
||||
t.Errorf("unexpected frame: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFrame_RejectsZeroSize(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "x", Width: 0, Height: 50}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("zero width should be ErrInvalidInput; got %v", err)
|
||||
}
|
||||
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "y", Width: 50, Height: 0}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("zero height should be ErrInvalidInput; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFrame_DuplicateNameInSameProject(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 200, Height: 70}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("duplicate frame name should ErrConflict; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFrame_SameNameAcrossProjectsOK(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
if _, err := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
|
||||
t.Fatalf("p1: %v", err)
|
||||
}
|
||||
if _, err := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
|
||||
t.Fatalf("p2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFrame_WrongProjectIsNotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
f, _ := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
if _, err := s.GetFrame(p2.ID, f.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("cross-project GetFrame should be ErrNotFound; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFrames_OrderedByCreation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
for _, n := range []string{"rack", "desk", "media"} {
|
||||
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: n, Width: 100, Height: 50}); err != nil {
|
||||
t.Fatalf("create %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
got, _ := s.ListFrames(p.ID)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d", len(got))
|
||||
}
|
||||
if got[0].Name != "rack" || got[2].Name != "media" {
|
||||
t.Errorf("order = %v", []string{got[0].Name, got[1].Name, got[2].Name})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFrame_PartialFields(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 0, Y: 0, Width: 100, Height: 50})
|
||||
nx := 42.0
|
||||
updated, err := s.UpdateFrame(p.ID, f.ID, FrameUpdate{X: &nx})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.X != 42 || updated.Name != "desk" || updated.Width != 100 {
|
||||
t.Errorf("got %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFrame_SetsDeviceFrameIDToNull(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
|
||||
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, X: 10, Y: 20, Width: 100, Height: 35})
|
||||
if d.FrameID == nil || *d.FrameID != f.ID {
|
||||
t.Fatalf("device frame_id pre-delete = %v, want %d", d.FrameID, f.ID)
|
||||
}
|
||||
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
|
||||
t.Fatalf("delete frame: %v", err)
|
||||
}
|
||||
d2, _ := s.GetDevice(p.ID, d.ID)
|
||||
if d2.FrameID != nil {
|
||||
t.Errorf("device frame_id post-delete = %v, want nil (SET NULL)", d2.FrameID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- devices
|
||||
|
||||
func TestCreateDevice_DefaultsColor(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
d, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 20, Width: 100, Height: 35})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if d.Color != "#1e1e1e" {
|
||||
t.Errorf("default color = %q, want #1e1e1e", d.Color)
|
||||
}
|
||||
if d.FrameID != nil {
|
||||
t.Errorf("frame_id = %v, want nil for unframed device", d.FrameID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDevice_DuplicateNameInProject(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35}); err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 10, Width: 100, Height: 35}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("dup device name should ErrConflict; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDevice_CrossProjectFrameRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
// Try to put a LOFT device into an OFFICE frame.
|
||||
_, err := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", FrameID: &f2.ID, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDevice_FrameIDTriState(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
|
||||
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f1.ID, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
|
||||
// Leave alone (FrameID.Set=false) — even passing a different X.
|
||||
nx := 99.0
|
||||
u1, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{X: &nx})
|
||||
if u1.FrameID == nil || *u1.FrameID != f1.ID {
|
||||
t.Errorf("frame_id should be unchanged (f1); got %v", u1.FrameID)
|
||||
}
|
||||
|
||||
// Move to f2.
|
||||
u2, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
|
||||
if u2.FrameID == nil || *u2.FrameID != f2.ID {
|
||||
t.Errorf("frame_id should be f2; got %v", u2.FrameID)
|
||||
}
|
||||
|
||||
// Clear (move outside any frame).
|
||||
u3, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: nil}})
|
||||
if u3.FrameID != nil {
|
||||
t.Errorf("frame_id should be nil after Set:true,ID:nil; got %v", *u3.FrameID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDevice_RejectsCrossProjectFrame(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
d, _ := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35})
|
||||
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
_, err := s.UpdateDevice(p1.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDevices_FilterByFrame(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
|
||||
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "A", FrameID: &f1.ID, Width: 100, Height: 35})
|
||||
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "B", FrameID: &f2.ID, Width: 100, Height: 35})
|
||||
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "C", Width: 100, Height: 35}) // outside
|
||||
|
||||
all, _ := s.ListDevices(p.ID, nil)
|
||||
if len(all) != 3 {
|
||||
t.Errorf("all len = %d, want 3", len(all))
|
||||
}
|
||||
inF1, _ := s.ListDevices(p.ID, &f1.ID)
|
||||
if len(inF1) != 1 || inF1[0].Name != "A" {
|
||||
t.Errorf("inF1 = %+v", inF1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_PopulatesFramesAndDevices(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, Width: 100, Height: 35})
|
||||
snap, err := s.Snapshot(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if len(snap.Frames) != 1 || len(snap.Devices) != 1 {
|
||||
t.Errorf("snapshot frames=%d devices=%d", len(snap.Frames), len(snap.Devices))
|
||||
}
|
||||
if len(snap.CableTypes) != 5 {
|
||||
t.Errorf("cable_types = %d, want 5", len(snap.CableTypes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDevice_NotFoundIsNotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if err := s.DeleteDevice(p.ID, 999); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,43 @@ type CableType struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Frame is a sub-zone inside a project (`desk`, `rack`, …).
|
||||
type Frame struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Device is a hardware item inside a project, optionally inside a frame.
|
||||
type Device struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Snapshot is the editor's one-shot loader payload for a single project.
|
||||
// Slice 1 returns the project + the global cable_types; the other arrays
|
||||
// are present but empty until later slices ship their CRUD.
|
||||
// Arrays for collections still gated by future slices stay non-nil [] so
|
||||
// JSON encodes as [] not null.
|
||||
type Snapshot struct {
|
||||
Project Project `json:"project"`
|
||||
Frames []any `json:"frames"`
|
||||
Devices []any `json:"devices"`
|
||||
Frames []Frame `json:"frames"`
|
||||
Devices []Device `json:"devices"`
|
||||
Ports []any `json:"ports"`
|
||||
Cables []any `json:"cables"`
|
||||
IOMarkers []any `json:"io_markers"`
|
||||
|
||||
@@ -147,8 +147,9 @@ func (s *Store) DeleteProject(id int64, confirmName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot loads the full editor-init payload for one project. In slice
|
||||
// 1 the project-scoped collections are still empty.
|
||||
// Snapshot loads the full editor-init payload for one project. Slice 2
|
||||
// populates frames + devices; ports / cables / io_markers / bundles
|
||||
// still ship empty until their slices land.
|
||||
func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
p, err := s.GetProject(id)
|
||||
if err != nil {
|
||||
@@ -158,10 +159,18 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frames, err := s.ListFrames(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices, err := s.ListDevices(id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Snapshot{
|
||||
Project: *p,
|
||||
Frames: []any{},
|
||||
Devices: []any{},
|
||||
Frames: frames,
|
||||
Devices: devices,
|
||||
Ports: []any{},
|
||||
Cables: []any{},
|
||||
IOMarkers: []any{},
|
||||
|
||||
234
internal/server/frames_devices.go
Normal file
234
internal/server/frames_devices.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- frames
|
||||
|
||||
type frameCreate struct {
|
||||
Name string `json:"name"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
type framePatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listFrames(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
fs, err := h.store.ListFrames(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fs)
|
||||
}
|
||||
|
||||
func (h *handlers) createFrame(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body frameCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
f, err := h.store.CreateFrame(pid, db.FrameCreate{
|
||||
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, f)
|
||||
}
|
||||
|
||||
func (h *handlers) patchFrame(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body framePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
f, err := h.store.UpdateFrame(pid, id, db.FrameUpdate{
|
||||
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, f)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteFrame(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteFrame(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- devices
|
||||
|
||||
type deviceCreate struct {
|
||||
Name string `json:"name"`
|
||||
FrameID *int64 `json:"frame_id,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
// devicePatch uses a raw `json.RawMessage` for frame_id so we can tell
|
||||
// "key absent" (leave alone) from "key present and null" (set to NULL)
|
||||
// from "key present with an int" (move to that frame). Standard encoding
|
||||
// of nullable fields in JSON PATCH.
|
||||
type devicePatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// parseFrameRef decodes the raw frame_id field into a tri-state.
|
||||
func parseFrameRef(raw json.RawMessage) (db.FrameRef, error) {
|
||||
if len(raw) == 0 {
|
||||
return db.FrameRef{Set: false}, nil
|
||||
}
|
||||
// "null" → clear; otherwise expect an integer.
|
||||
if string(raw) == "null" {
|
||||
return db.FrameRef{Set: true, ID: nil}, nil
|
||||
}
|
||||
var id int64
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
return db.FrameRef{}, err
|
||||
}
|
||||
return db.FrameRef{Set: true, ID: &id}, nil
|
||||
}
|
||||
|
||||
func (h *handlers) listDevices(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
ds, err := h.store.ListDevices(pid, nil)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ds)
|
||||
}
|
||||
|
||||
func (h *handlers) createDevice(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body deviceCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
d, err := h.store.CreateDevice(pid, db.DeviceCreate{
|
||||
Name: body.Name, FrameID: body.FrameID, Color: body.Color,
|
||||
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, d)
|
||||
}
|
||||
|
||||
func (h *handlers) patchDevice(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body devicePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
ref, err := parseFrameRef(body.FrameID)
|
||||
if err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
|
||||
return
|
||||
}
|
||||
d, err := h.store.UpdateDevice(pid, id, db.DeviceUpdate{
|
||||
Name: body.Name, FrameID: ref, Color: body.Color,
|
||||
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteDevice(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteDevice(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -33,8 +33,39 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux.HandleFunc("PATCH /api/cable-types/{id}", h.patchCableType)
|
||||
mux.HandleFunc("DELETE /api/cable-types/{id}", h.deleteCableType)
|
||||
|
||||
// Frames (project-scoped)
|
||||
mux.HandleFunc("GET /api/projects/{pid}/frames", h.listFrames)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/frames", h.createFrame)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/frames/{id}", h.patchFrame)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/frames/{id}", h.deleteFrame)
|
||||
|
||||
// Devices (project-scoped)
|
||||
mux.HandleFunc("GET /api/projects/{pid}/devices", h.listDevices)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/devices", h.createDevice)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
|
||||
|
||||
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||
mux.Handle("/", http.FileServerFS(frontend))
|
||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||
// the file server already emits — without this, browsers cache aggressively
|
||||
// and m sees the old main.js after every redeploy until hard-reload.
|
||||
mux.Handle("/", noCache(http.FileServerFS(frontend)))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// noCache wraps a static handler so each response carries
|
||||
// Cache-Control: no-cache. Combined with the ETag/Last-Modified headers
|
||||
// http.FileServer(FS) already emits, this turns every fetch into a
|
||||
// cheap revalidation request — the browser uses its cached body when
|
||||
// the ETag matches but always asks first, so freshly-built assets show
|
||||
// up on the next page load without a hard-reload.
|
||||
//
|
||||
// Applied to the static-asset handler only — API responses write their
|
||||
// own headers and aren't routed through this.
|
||||
func noCache(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
<section class="tools">
|
||||
<h2 class="sidebar-heading">Tools</h2>
|
||||
<ul class="tool-list">
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Frame</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Device</button></li>
|
||||
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
|
||||
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
|
||||
</ul>
|
||||
@@ -58,7 +58,9 @@
|
||||
|
||||
<aside class="inspector" aria-label="Inspector">
|
||||
<h2 class="sidebar-heading">Inspector</h2>
|
||||
<p class="muted">Nothing selected.</p>
|
||||
<div id="inspector-body">
|
||||
<p class="muted">Nothing selected.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
// mCables frontend entry — vanilla ES module, no build step.
|
||||
//
|
||||
// Slice 1 covers: list/create/delete projects, list/create/edit/delete
|
||||
// global cable types, and reflect the active project in ?project=<id>.
|
||||
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
|
||||
// inline naming, inspector for selection. State stays minimal: one
|
||||
// snapshot from the server, then individual PATCHes on each mutation.
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, drawing_name: string,
|
||||
* description: string, created_at: string, updated_at: string }} Project
|
||||
* @typedef {{ id: number, name: string, color: string,
|
||||
* created_at: string, updated_at: string }} CableType
|
||||
* @typedef {{ id: number, project_id: number, name: string,
|
||||
* x: number, y: number, width: number, height: number }} Frame
|
||||
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
||||
* name: string, color: string,
|
||||
* x: number, y: number, width: number, height: number }} Device
|
||||
*/
|
||||
|
||||
const API = "/api";
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const state = {
|
||||
/** @type {Project[]} */ projects: [],
|
||||
/** @type {CableType[]} */ cableTypes: [],
|
||||
/** @type {Project | null} */ active: null,
|
||||
/** active cable-type id (used for drawing in later slices) */
|
||||
activeTypeId: null,
|
||||
/** @type {Frame[]} */ frames: [],
|
||||
/** @type {Device[]} */ devices: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | null */
|
||||
tool: /** @type {string|null} */ (null),
|
||||
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
|
||||
};
|
||||
|
||||
// ---------- API client ---------- //
|
||||
@@ -42,7 +53,6 @@ async function api(method, path, body) {
|
||||
|
||||
const listProjects = () => api("GET", "/projects");
|
||||
const createProject = (body) => api("POST", "/projects", body);
|
||||
const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body);
|
||||
const deleteProject = (id, confirm) =>
|
||||
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
|
||||
const getSnapshot = (id) => api("GET", `/projects/${id}`);
|
||||
@@ -52,6 +62,14 @@ const createCableType = (body) => api("POST", "/cable-types", body);
|
||||
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
|
||||
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
|
||||
|
||||
const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body);
|
||||
const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body);
|
||||
const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`);
|
||||
|
||||
const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body);
|
||||
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
|
||||
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
|
||||
|
||||
// ---------- DOM helpers ---------- //
|
||||
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
@@ -61,6 +79,15 @@ function setHidden(el, hidden) {
|
||||
else el.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
function svgEl(name, attrs = {}) {
|
||||
const el = document.createElementNS(SVG_NS, name);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v == null) continue;
|
||||
el.setAttribute(k, String(v));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------- URL state ---------- //
|
||||
|
||||
function activeProjectIdFromURL() {
|
||||
@@ -76,14 +103,39 @@ function setActiveInURL(id) {
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
// ---------- geometry ---------- //
|
||||
|
||||
/** Returns the smallest frame whose bbox contains (x, y), or null. */
|
||||
function frameAt(x, y) {
|
||||
/** @type {Frame|null} */ let best = null;
|
||||
let bestArea = Infinity;
|
||||
for (const f of state.frames) {
|
||||
if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue;
|
||||
const a = f.width * f.height;
|
||||
if (a < bestArea) { best = f; bestArea = a; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Convert a pointer event to SVG-canvas coordinates. */
|
||||
function svgPoint(evt) {
|
||||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = evt.clientX;
|
||||
pt.y = evt.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: 0, y: 0 };
|
||||
const local = pt.matrixTransform(ctm.inverse());
|
||||
return { x: local.x, y: local.y };
|
||||
}
|
||||
|
||||
// ---------- render ---------- //
|
||||
|
||||
function renderProjectPicker() {
|
||||
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
|
||||
const current = state.active?.id ?? "";
|
||||
sel.innerHTML = "";
|
||||
const blank = new Option("— pick a project —", "");
|
||||
sel.append(blank);
|
||||
sel.append(new Option("— pick a project —", ""));
|
||||
for (const p of state.projects) {
|
||||
const opt = new Option(p.name, String(p.id));
|
||||
if (p.id === current) opt.selected = true;
|
||||
@@ -126,16 +178,214 @@ function renderEmptyHint() {
|
||||
? "Pick a project from the dropdown to start drawing."
|
||||
: "Create your first project to get started.";
|
||||
setHidden(hint, false);
|
||||
} else {
|
||||
hint.textContent = `${state.active.name} — slice 1: empty canvas. Frames + devices arrive in slice 2.`;
|
||||
setHidden(hint, false);
|
||||
return;
|
||||
}
|
||||
if (state.frames.length === 0 && state.devices.length === 0) {
|
||||
hint.textContent = `${state.active.name} — empty. Use + Frame / + Device to start (press F or D).`;
|
||||
setHidden(hint, false);
|
||||
} else {
|
||||
setHidden(hint, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
const gFrames = $("#canvas-frames");
|
||||
const gDevices = $("#canvas-devices");
|
||||
gFrames.innerHTML = "";
|
||||
gDevices.innerHTML = "";
|
||||
|
||||
for (const f of state.frames) {
|
||||
const g = svgEl("g", { "data-frame-id": f.id });
|
||||
const rect = svgEl("rect", {
|
||||
x: f.x, y: f.y, width: f.width, height: f.height,
|
||||
class: "frame-rect svg-draggable",
|
||||
rx: 6, ry: 6,
|
||||
});
|
||||
if (state.selection?.kind === "frame" && state.selection.id === f.id) {
|
||||
rect.classList.add("selected");
|
||||
}
|
||||
const label = svgEl("text", {
|
||||
x: f.x + 8, y: f.y + 18,
|
||||
class: "frame-label",
|
||||
});
|
||||
label.textContent = f.name;
|
||||
g.append(rect, label);
|
||||
gFrames.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
|
||||
}
|
||||
|
||||
for (const d of state.devices) {
|
||||
const g = svgEl("g", { "data-device-id": d.id });
|
||||
const rect = svgEl("rect", {
|
||||
x: d.x, y: d.y, width: d.width, height: d.height,
|
||||
class: "device-rect svg-draggable",
|
||||
stroke: d.color,
|
||||
rx: 3, ry: 3,
|
||||
});
|
||||
if (state.selection?.kind === "device" && state.selection.id === d.id) {
|
||||
rect.classList.add("selected");
|
||||
}
|
||||
const label = svgEl("text", {
|
||||
x: d.x + d.width / 2, y: d.y + d.height / 2,
|
||||
class: "device-label",
|
||||
});
|
||||
label.textContent = d.name;
|
||||
g.append(rect, label);
|
||||
gDevices.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
const body = $("#inspector-body");
|
||||
if (!state.selection) {
|
||||
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||||
return;
|
||||
}
|
||||
if (state.selection.kind === "frame") {
|
||||
renderInspectorFrame(body, state.selection.id);
|
||||
} else {
|
||||
renderInspectorDevice(body, state.selection.id);
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspectorFrame(body, id) {
|
||||
const f = state.frames.find((x) => x.id === id);
|
||||
if (!f) { body.innerHTML = ""; return; }
|
||||
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Frame</p>
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input class="inline-input" id="frm-name" value="" />
|
||||
</label>
|
||||
<dl>
|
||||
<dt>x</dt><dd id="frm-x"></dd>
|
||||
<dt>y</dt><dd id="frm-y"></dd>
|
||||
<dt>w</dt><dd id="frm-w"></dd>
|
||||
<dt>h</dt><dd id="frm-h"></dd>
|
||||
<dt>devices</dt><dd id="frm-count"></dd>
|
||||
</dl>
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#frm-name").value = f.name;
|
||||
body.querySelector("#frm-x").textContent = f.x.toFixed(0);
|
||||
body.querySelector("#frm-y").textContent = f.y.toFixed(0);
|
||||
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
|
||||
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
|
||||
body.querySelector("#frm-count").textContent = String(deviceCount);
|
||||
|
||||
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
|
||||
if (!state.active) return;
|
||||
const updated = await patchFrame(state.active.id, f.id, { name });
|
||||
Object.assign(f, updated);
|
||||
renderCanvas();
|
||||
});
|
||||
|
||||
body.querySelector("#frm-delete").addEventListener("click", () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm(`Delete frame "${f.name}"? Its devices stay but lose their frame.`)) return;
|
||||
deleteFrame(state.active.id, f.id).then(() => {
|
||||
state.frames = state.frames.filter((x) => x.id !== f.id);
|
||||
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
|
||||
state.selection = null;
|
||||
render();
|
||||
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspectorDevice(body, id) {
|
||||
const d = state.devices.find((x) => x.id === id);
|
||||
if (!d) { body.innerHTML = ""; return; }
|
||||
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Device</p>
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input class="inline-input" id="dev-name" value="" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Colour</span>
|
||||
<input type="color" class="inline-input" id="dev-color" />
|
||||
</label>
|
||||
<dl>
|
||||
<dt>x</dt><dd id="dev-x"></dd>
|
||||
<dt>y</dt><dd id="dev-y"></dd>
|
||||
<dt>w</dt><dd id="dev-w"></dd>
|
||||
<dt>h</dt><dd id="dev-h"></dd>
|
||||
<dt>frame</dt><dd id="dev-frame"></dd>
|
||||
</dl>
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#dev-name").value = d.name;
|
||||
body.querySelector("#dev-color").value = d.color;
|
||||
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
|
||||
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
|
||||
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
|
||||
body.querySelector("#dev-h").textContent = d.height.toFixed(0);
|
||||
body.querySelector("#dev-frame").textContent = frame ? frame.name : "—";
|
||||
|
||||
bindDebouncedRename(body.querySelector("#dev-name"), async (name) => {
|
||||
if (!state.active) return;
|
||||
const updated = await patchDevice(state.active.id, d.id, { name });
|
||||
Object.assign(d, updated);
|
||||
renderCanvas();
|
||||
});
|
||||
|
||||
// Colour changes need no debounce — the native colour picker only fires
|
||||
// `change` on commit.
|
||||
body.querySelector("#dev-color").addEventListener("change", async (e) => {
|
||||
if (!state.active) return;
|
||||
const color = /** @type {HTMLInputElement} */ (e.target).value;
|
||||
try {
|
||||
const updated = await patchDevice(state.active.id, d.id, { color });
|
||||
Object.assign(d, updated);
|
||||
renderCanvas();
|
||||
} catch (err) {
|
||||
alert(`Colour update failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
body.querySelector("#dev-delete").addEventListener("click", () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm(`Delete device "${d.name}"?`)) return;
|
||||
deleteDevice(state.active.id, d.id).then(() => {
|
||||
state.devices = state.devices.filter((x) => x.id !== d.id);
|
||||
state.selection = null;
|
||||
render();
|
||||
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
||||
});
|
||||
}
|
||||
|
||||
function bindDebouncedRename(input, persist) {
|
||||
let timer = null;
|
||||
input.addEventListener("input", () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const v = input.value.trim();
|
||||
if (v) persist(v).catch((e) => alert(`Save failed: ${e.message}`));
|
||||
}, 400);
|
||||
});
|
||||
input.addEventListener("blur", () => {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
const v = input.value.trim();
|
||||
if (v && v !== input.dataset.last) {
|
||||
persist(v).catch((e) => alert(`Save failed: ${e.message}`));
|
||||
input.dataset.last = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderProjectPicker();
|
||||
renderLegend();
|
||||
renderCanvas();
|
||||
renderEmptyHint();
|
||||
renderInspector();
|
||||
}
|
||||
|
||||
// ---------- active project ---------- //
|
||||
@@ -143,6 +393,9 @@ function render() {
|
||||
async function activateProject(id) {
|
||||
if (id == null) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
return;
|
||||
@@ -150,15 +403,17 @@ async function activateProject(id) {
|
||||
try {
|
||||
const snap = await getSnapshot(id);
|
||||
state.active = snap.project;
|
||||
// The snapshot also returns the global cable types — refresh from
|
||||
// the source of truth so a stale state.cableTypes can never linger.
|
||||
state.frames = snap.frames || [];
|
||||
state.devices = snap.devices || [];
|
||||
state.cableTypes = snap.cable_types || [];
|
||||
state.selection = null;
|
||||
setActiveInURL(id);
|
||||
render();
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
// The id in the URL points to a deleted project — clear it.
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
@@ -167,7 +422,287 @@ async function activateProject(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- modals ---------- //
|
||||
// ---------- tools ---------- //
|
||||
|
||||
function armTool(tool) {
|
||||
if (state.tool === tool) tool = null; // toggle off
|
||||
state.tool = tool;
|
||||
const wrap = $(".canvas-wrap");
|
||||
wrap.classList.toggle("tool-frame", tool === "frame");
|
||||
wrap.classList.toggle("tool-device", tool === "device");
|
||||
for (const btn of document.querySelectorAll("[data-tool]")) {
|
||||
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
|
||||
}
|
||||
}
|
||||
|
||||
function bindTools() {
|
||||
for (const btn of document.querySelectorAll("[data-tool]")) {
|
||||
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
// Avoid stealing keys while user is typing into an input.
|
||||
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
|
||||
else if (e.key === "f" || e.key === "F") armTool("frame");
|
||||
else if (e.key === "d" || e.key === "D") armTool("device");
|
||||
});
|
||||
|
||||
// Canvas-level pointerdown handles tool activation + selection clearing.
|
||||
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
|
||||
}
|
||||
|
||||
let rubberBand = /** @type {SVGRectElement|null} */ (null);
|
||||
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
|
||||
|
||||
function onCanvasPointerDown(e) {
|
||||
if (!state.active) return;
|
||||
|
||||
const p = svgPoint(e);
|
||||
|
||||
// Armed tool wins: a click anywhere on the canvas — including on top
|
||||
// of an existing frame or device — fires the tool. The +Dev tool needs
|
||||
// this so m can drop a device inside a frame; without it the frame's
|
||||
// own pointerdown handler would steal the click and start a drag.
|
||||
//
|
||||
// e.preventDefault() suppresses the compatibility mousedown's default
|
||||
// focus-shift. Without it, the freshly-focused inline-namer input gets
|
||||
// blurred ~6ms later by the browser's "focus nearest focusable ancestor
|
||||
// or blur active" behaviour (SVG rects are not focusable), and the
|
||||
// blur handler tears the namer down before m can type. Root cause +
|
||||
// verified fix from sherlock's Playwright shift; see docs/sherlock-+dev-bug.md
|
||||
// for the full trace.
|
||||
if (state.tool === "frame") {
|
||||
e.preventDefault();
|
||||
startFrameRubberBand(e, p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "device") {
|
||||
e.preventDefault();
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool armed: clicks that started on a device/frame go to their
|
||||
// own handlers (drag / select). Leave them alone.
|
||||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id]")) return;
|
||||
|
||||
// Plain canvas click = clear selection.
|
||||
if (state.selection) { state.selection = null; render(); }
|
||||
}
|
||||
|
||||
function startFrameRubberBand(e, p0) {
|
||||
if (!state.active) return;
|
||||
rubberStart = p0;
|
||||
rubberBand = svgEl("rect", {
|
||||
x: p0.x, y: p0.y, width: 0, height: 0,
|
||||
class: "rubber-band", rx: 6, ry: 6,
|
||||
});
|
||||
$("#canvas").append(rubberBand);
|
||||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
|
||||
const onMove = (ev) => {
|
||||
if (!rubberBand || !rubberStart) return;
|
||||
const p = svgPoint(ev);
|
||||
const x = Math.min(rubberStart.x, p.x);
|
||||
const y = Math.min(rubberStart.y, p.y);
|
||||
rubberBand.setAttribute("x", String(x));
|
||||
rubberBand.setAttribute("y", String(y));
|
||||
rubberBand.setAttribute("width", String(Math.abs(p.x - rubberStart.x)));
|
||||
rubberBand.setAttribute("height", String(Math.abs(p.y - rubberStart.y)));
|
||||
};
|
||||
const onUp = async (ev) => {
|
||||
svg.removeEventListener("pointermove", onMove);
|
||||
svg.removeEventListener("pointerup", onUp);
|
||||
svg.releasePointerCapture(e.pointerId);
|
||||
const rect = rubberBand;
|
||||
const start = rubberStart;
|
||||
rubberBand = null;
|
||||
rubberStart = null;
|
||||
if (!rect || !start) return;
|
||||
const w = Number(rect.getAttribute("width"));
|
||||
const h = Number(rect.getAttribute("height"));
|
||||
const x = Number(rect.getAttribute("x"));
|
||||
const y = Number(rect.getAttribute("y"));
|
||||
rect.remove();
|
||||
if (w < 80 || h < 60) { armTool(null); return; }
|
||||
armTool(null);
|
||||
const name = await promptInline("Frame name", x + w / 2, y + 16);
|
||||
if (!name || !state.active) return;
|
||||
try {
|
||||
const f = await createFrame(state.active.id, { name, x, y, width: w, height: h });
|
||||
state.frames.push(f);
|
||||
state.selection = { kind: "frame", id: f.id };
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(`Create frame failed: ${err.message}`);
|
||||
}
|
||||
};
|
||||
svg.addEventListener("pointermove", onMove);
|
||||
svg.addEventListener("pointerup", onUp);
|
||||
}
|
||||
|
||||
async function placeDeviceAt(p) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
const W = 100, H = 35;
|
||||
const x = p.x - W / 2;
|
||||
const y = p.y - H / 2;
|
||||
const name = await promptInline("Device name", p.x, p.y);
|
||||
if (!name || !state.active) return;
|
||||
const frame = frameAt(p.x, p.y);
|
||||
try {
|
||||
const d = await createDevice(state.active.id, {
|
||||
name, x, y, width: W, height: H,
|
||||
frame_id: frame ? frame.id : undefined,
|
||||
});
|
||||
state.devices.push(d);
|
||||
state.selection = { kind: "device", id: d.id };
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(`Create device failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- inline namer (foreignObject overlay) ---------- //
|
||||
|
||||
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
|
||||
|
||||
function cancelInlineNamer() {
|
||||
if (activeNamer) { activeNamer.remove(); activeNamer = null; }
|
||||
}
|
||||
|
||||
function promptInline(placeholder, cx, cy) {
|
||||
cancelInlineNamer();
|
||||
return new Promise((resolve) => {
|
||||
const fo = document.createElementNS(SVG_NS, "foreignObject");
|
||||
fo.setAttribute("x", String(cx - 110));
|
||||
fo.setAttribute("y", String(cy - 14));
|
||||
fo.setAttribute("width", "220");
|
||||
fo.setAttribute("height", "28");
|
||||
fo.innerHTML = `
|
||||
<div class="inline-namer" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<input type="text" placeholder="${placeholder}" />
|
||||
</div>
|
||||
`;
|
||||
$("#canvas").append(fo);
|
||||
activeNamer = fo;
|
||||
const input = fo.querySelector("input");
|
||||
input.focus();
|
||||
const done = (val) => {
|
||||
// Clear the flag *before* removing the node. Enter-key triggers a
|
||||
// synchronous blur on the input, which re-enters done() — and if
|
||||
// fo.remove() ran first, the second call hits a
|
||||
// "node no longer a child" pageerror. Reordering makes the second
|
||||
// re-entry a no-op (activeNamer is already null).
|
||||
if (activeNamer !== fo) return;
|
||||
activeNamer = null;
|
||||
fo.remove();
|
||||
resolve(val);
|
||||
};
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") done(input.value.trim());
|
||||
else if (e.key === "Escape") done(null);
|
||||
});
|
||||
input.addEventListener("blur", () => done(input.value.trim() || null));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- drag ---------- //
|
||||
|
||||
function startDrag(e, kind, id) {
|
||||
if (!state.active) return;
|
||||
if (state.tool) return; // a tool is armed; don't hijack
|
||||
e.stopPropagation();
|
||||
state.selection = { kind, id };
|
||||
// Render immediately so the inspector reflects the new selection from
|
||||
// pointerdown — independent of whether the drag-completion render at
|
||||
// the end of onUp runs. (Previously, the inspector only updated if
|
||||
// pointerup completed cleanly; any throw in onUp left it stale.)
|
||||
render();
|
||||
|
||||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||||
const start = svgPoint(e);
|
||||
/** @type {Frame|Device|undefined} */
|
||||
const obj = kind === "frame"
|
||||
? state.frames.find((f) => f.id === id)
|
||||
: state.devices.find((d) => d.id === id);
|
||||
if (!obj) return;
|
||||
const startX = obj.x;
|
||||
const startY = obj.y;
|
||||
|
||||
// For frame drags, remember the contained devices + their offsets so
|
||||
// they follow the frame visually + persist on release.
|
||||
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
|
||||
if (kind === "frame") {
|
||||
for (const d of state.devices) {
|
||||
if (d.frame_id === obj.id) {
|
||||
trackedDevices.push({ d, sx: d.x, sy: d.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.currentTarget.classList.add("dragging");
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
|
||||
let dragged = false;
|
||||
|
||||
const onMove = (ev) => {
|
||||
const p = svgPoint(ev);
|
||||
const dx = p.x - start.x;
|
||||
const dy = p.y - start.y;
|
||||
if (!dragged && (Math.abs(dx) + Math.abs(dy) > 1)) dragged = true;
|
||||
obj.x = startX + dx;
|
||||
obj.y = startY + dy;
|
||||
if (kind === "frame") {
|
||||
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
|
||||
}
|
||||
renderCanvas();
|
||||
};
|
||||
const onUp = async (ev) => {
|
||||
svg.removeEventListener("pointermove", onMove);
|
||||
svg.removeEventListener("pointerup", onUp);
|
||||
svg.releasePointerCapture(e.pointerId);
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
|
||||
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
|
||||
if (!state.active) return;
|
||||
|
||||
try {
|
||||
if (kind === "frame") {
|
||||
const f = /** @type {Frame} */ (obj);
|
||||
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
|
||||
// Persist contained devices too.
|
||||
await Promise.all(
|
||||
trackedDevices.map((t) =>
|
||||
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
|
||||
);
|
||||
} else {
|
||||
const d = /** @type {Device} */ (obj);
|
||||
// Recompute frame_id from drop point (centre of device).
|
||||
const cx = d.x + d.width / 2;
|
||||
const cy = d.y + d.height / 2;
|
||||
const targetFrame = frameAt(cx, cy);
|
||||
const newFrameID = targetFrame ? targetFrame.id : null;
|
||||
const patchBody = { x: d.x, y: d.y };
|
||||
if ((d.frame_id ?? null) !== newFrameID) {
|
||||
patchBody.frame_id = newFrameID; // explicit null = clear
|
||||
d.frame_id = newFrameID;
|
||||
}
|
||||
await patchDevice(state.active.id, d.id, patchBody);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Save failed: ${err.message}`);
|
||||
}
|
||||
render();
|
||||
};
|
||||
svg.addEventListener("pointermove", onMove);
|
||||
svg.addEventListener("pointerup", onUp);
|
||||
}
|
||||
|
||||
// ---------- modals (project / cable type) ---------- //
|
||||
|
||||
function bindCloseButtons(dialog) {
|
||||
dialog.querySelectorAll("[data-close]").forEach((btn) =>
|
||||
@@ -226,7 +761,6 @@ function openCableTypeModal(existing) {
|
||||
form.elements.namedItem("color").value = "#1971c2";
|
||||
}
|
||||
|
||||
// Slot in a Delete button when editing an existing type.
|
||||
const actions = form.querySelector(".actions");
|
||||
actions.querySelector(".btn-delete-type")?.remove();
|
||||
if (existing) {
|
||||
@@ -314,6 +848,8 @@ async function boot() {
|
||||
activateProject(v ? Number(v) : null);
|
||||
});
|
||||
|
||||
bindTools();
|
||||
|
||||
try {
|
||||
[state.projects, state.cableTypes] = await Promise.all([
|
||||
listProjects(),
|
||||
|
||||
@@ -165,6 +165,128 @@ body {
|
||||
|
||||
.muted { color: var(--text-muted); }
|
||||
|
||||
/* ---------- canvas elements ---------- */
|
||||
|
||||
.frame-rect {
|
||||
fill: rgba(25, 113, 194, 0.04);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
.frame-rect.selected,
|
||||
.frame-rect:hover { stroke-width: 2.5; }
|
||||
|
||||
.frame-label {
|
||||
fill: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.device-rect {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.device-rect.selected { stroke-width: 3; }
|
||||
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
|
||||
|
||||
.device-label {
|
||||
fill: var(--text);
|
||||
font-size: 12px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.svg-draggable { cursor: grab; }
|
||||
.svg-draggable.dragging { cursor: grabbing; }
|
||||
|
||||
/* Tool cursor while a tool is armed. The `* { ... !important }` descendant
|
||||
rule is the load-bearing part: without it, the `.svg-draggable` rules
|
||||
on individual frame/device rects win by element specificity and
|
||||
override the SVG-root cursor — so hovering a frame with +Dev armed
|
||||
shows `grab`, which lies about what a click will do. */
|
||||
.canvas-wrap.tool-frame #canvas,
|
||||
.canvas-wrap.tool-frame #canvas *,
|
||||
.canvas-wrap.tool-device #canvas,
|
||||
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* tool buttons toggle armed-state */
|
||||
.btn[data-tool].armed {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- inspector ---------- */
|
||||
|
||||
.inspector dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inspector dt { color: var(--text-muted); }
|
||||
.inspector dd { margin: 0; }
|
||||
|
||||
.inspector .inline-input {
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
.inspector .inline-input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.inspector .section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 12px 0 6px 0;
|
||||
}
|
||||
.inspector .inspector-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* foreignObject used to inline-name a freshly-placed frame/device */
|
||||
.inline-namer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.inline-namer input {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
width: calc(100% - 8px);
|
||||
max-width: 200px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
.btn {
|
||||
|
||||
Reference in New Issue
Block a user