Compare commits

...

16 Commits

Author SHA1 Message Date
mAi
6cd5925f4c feat(ui): admin modal — projects + cable types + device types + templates
Header gear ('⚙ Admin') opens a wide modal with four tabs:

- **Projects** — list, rename, edit drawing_name + description, delete
  with typed-name confirm. Wires the existing PATCH /projects/:id and
  DELETE /projects/:id?confirm=<name> endpoints; renaming was previously
  only reachable via the API.
- **Cable types** — full CRUD with the global-scope banner. Mirrors the
  legend's quick edit but in a tabular list, plus an inline "+ Add"
  form at the bottom.
- **Device types** — built-ins listed read-only with a locked badge
  showing kind, description, and port profile (each port row tinted
  with the cable_type's colour). Project-custom types under the active
  project get editable name / kind / icon / description + Delete.
  Port-profile editing on custom types is still deferred (port-profile
  reshape will land in a follow-up).
- **Setup templates** — read-only list of built-ins with member devices
  and connection requirements expanded under each.

The modal re-fetches projects / cable types / setup templates on open
so it reflects current state regardless of what m did via inspector
panes while it was closed.

Files:
- index.html: ⚙ Admin button + #modal-admin dialog scaffold.
- main.js: patchProject + createDeviceType/patchDeviceType/deleteDeviceType
  API helpers; openAdminModal + switchAdminTab + 4 render functions.
- style.css: .admin-shell / .admin-tabs / .admin-row + state classes.
2026-05-16 11:51:05 +02:00
mAi
9773063008 merge: port editor in sidebar — type + edge + name; +Port retired
Port inspector now has a Type dropdown (PATCH /ports/:id with
type_id), keeps edge picker + label input + delete + back-link.

Replaces the canvas-armed +Port tool with a sidebar 'Add port' form
(reached via +Port button in the device inspector). Form fields:
Type, Edge, Label with auto-default '<type> <next-index>' that stops
auto-updating once m hand-edits. Submit → POST → relayout edge for
even spacing → selection switches to the new port's editor.

Port rows in the device inspector's list now click-to-select.

Removed scaffolding: tool === 'port' branch, armPortTool,
placePortAt, snapToDeviceEdge, .tool-port cursor CSS.
2026-05-16 11:45:25 +02:00
mAi
61bc1dcf43 feat(ui): port editor + add-port form in the sidebar inspector
m: 'Add port' should be a sidebar form, not a two-step canvas gesture.

- Port inspector gains a Type dropdown (read /api/cable-types via
  state.cableTypes, PATCH /ports/:id with type_id). Edge picker + label
  + delete from prior shift are unchanged.
- New "Add port" form rendered from selection.kind === "port_new":
  Type / Edge / Label, Create + Cancel buttons. Default label is the
  next free index for the chosen type on this device ("HDMI 3" if two
  HDMIs already live there). Recomputes when m changes the type, but
  stops recomputing as soon as m hand-edits the label.
- +Port in the device inspector now flips selection to port_new,
  rendering the form. Submit → POST → switch to the new port's editor.
  No second canvas click required.
- Clicking a port row in the device inspector's port list selects that
  port and opens its editor (same surface as canvas-click).
- "← <device name>" back-link in both port editor and add-port form
  jumps back to the device inspector.

Removed: state.tool === "port" branch, armPortTool helper, placePortAt
function, .tool-port CSS, state.portToolDevice / portToolTypeID. The
canvas-armed +Port tool was the user-trip-wire perseus flagged; the
sidebar form replaces it entirely.

snapToDeviceEdge also removed — placePortAt was its only caller; the
edgeCentre + portEdge + relayoutEdge trio fully owns port placement
now.

Port rows in the device inspector get a hover background + pointer
cursor to read as clickable.
2026-05-16 11:40:45 +02:00
mAi
056777f1c1 merge: template-apply creates frame + grid-places devices inside
ApplyTemplate now creates a frame named after the template
('Living Room' etc, suffixed on collision), computes a uniform grid
(cols=min(ceil(sqrt(N)),4), rows=ceil(N/cols)), and places each
device inside the frame with frame_id set.

Frontend unchanged — activateProject re-hydrates the snapshot
including the new frame.

Tests cover frame creation + in-frame placement + name-collision
suffix. Verified on mDock: Living Room template → frame (200,200,
294×200) with TV/Soundbar at row 0 and ChromeCast wrapping to row 1.
2026-05-16 11:35:25 +02:00
mAi
2aff5eb04d feat(template): apply-template lands devices inside a named frame
Before: ApplyTemplate dropped devices in a horizontal row at fixed
canvas coords with frame_id NULL — devices appeared anywhere and m
had no way to express "these belong together".

Now: each apply creates a frame named after the template (suffixed
"…  2/3/…" on name collision) and lays the devices out in a uniform
grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped
at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the
new frame's id and grid-cell coords.

Schema unchanged. ApplyTemplateResult.frames_added carries the new
frame so the frontend can refresh the canvas without a full snapshot
reload.

Tests:
- TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is
  created with the template's name, every device has frame_id set,
  every device sits inside the frame rect, no two devices share a
  grid cell.
- TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing
  "Living Room" frame in the project ⇒ template's frame named
  "Living Room 2".
- Existing tests unchanged.
2026-05-16 11:30:32 +02:00
mAi
5c11bf33cb merge: port UX bundle — selection feedback + even-spacing + onUp + device colour
3 commits (491db73, b28fc0c, 86264d1):
- +Port now sets state.selection on the new port → inspector switches
  to the port panel + halo shows
- Ports relayout to even spacing along the affected edge on every
  add/delete/edge-change (no more invisible stacking)
- startDrag.onUp captures the rect in closure instead of reading
  currentTarget after pointerup (no more 'classList of null' spam)
- Device colour: dropped CSS stroke/fill hard-codes, inline style now
  paints the rect — picker actually changes the visible colour

All verified end-to-end on the deployed image.
2026-05-16 11:25:32 +02:00
mAi
86264d1284 fix(ui): device colour now actually shows on the canvas
CSS .device-rect hard-coded stroke + fill, overriding the
stroke=${d.color} SVG attribute the JS wrote. Author CSS beats
presentation attributes, so changing the device colour via the
inspector picker was invisible.

Drop the stroke/fill overrides from .device-rect; set both inline
on the rect element instead — stroke = the chosen colour, fill =
a 12% tint via color-mix so the device reads coloured without
becoming garish. Inline style beats class CSS, so the picker works.

Frames + IO markers don't currently expose a colour picker, so no
analogous fix needed there.
2026-05-16 11:23:47 +02:00
mAi
b28fc0c565 fix(ui): even-spacing relayout on every port-set change
m's stronger invariant: ports must never overlap and must line up on
their edge. Replace the slide-collision dedup with full even-spacing
re-layout — for N ports on an edge, position i goes to axis · i/(N+1)
for i=1..N.

- New portEdge(port, dev) — snaps a port's current offsets to the
  nearest of the four edges (same heuristic as snapToDeviceEdge).
- New relayoutEdge(deviceID, edge) — re-spaces every port on the
  device-edge and PATCHes the ones whose offsets actually change.
  Sort key: x_offset for top/bottom, y_offset for left/right —
  preserves m's "I dropped it roughly here" order.

Applied on:
- placePortAt — re-layout the edge after the new port is created.
- inspector edge picker — capture oldEdge, PATCH the port to the
  centre of newEdge, then re-layout BOTH old and new edges.
- port delete — re-layout the edge the deleted port was on so the
  survivors collapse back to even spacing.

snapToDeviceEdge reverted to its pre-dedup shape (drop the existingPorts
arg and resolveCollision helper); the layout invariant is owned by
relayoutEdge now. edgeOf folded into portEdge.
2026-05-16 11:19:16 +02:00
mAi
491db730eb fix(ui): +Port feedback + snap dedup + startDrag closure-capture
Three changes from sherlock's Playwright debug (docs/sherlock-+port-bug.md):

1. Select the freshly-placed port. placePortAt now sets
   state.selection = {kind:"port", id:port.id} before render() so the
   inspector switches to the port panel and the .selected halo makes
   the new circle visible — fixes m's "+Port does nothing" perception
   (the port WAS being created server-side; it just rendered invisibly
   stacked under an existing one and the inspector stayed on the device).

2. Snap-to-edge dedup. snapToDeviceEdge now takes the existing ports
   on the device; if the computed (xOff, yOff) lands within 8px of a
   peer on the same edge, slide along the edge in 16px steps until a
   free slot is found. Eliminates pixel-perfect port stacks.

3. startDrag closure-capture. onUp asynchronously referenced
   e.currentTarget after pointerup nulled it, throwing a TypeError
   in the console on every click-only device selection. Capture
   dragTarget in the outer closure and use that inside add/remove.
2026-05-16 11:12:13 +02:00
mAi
90157dfd14 merge: migration 006 — IOx-* and Multi-plug-* are power strips
m: 'IOx-8 should have 8 powerports on the front, one on the back'.
Migration 006 reshapes all 8 power-distribution types (IOx-3/6/8,
Multi-plug 3/4/5/6, Wifi-plug) into 1 Power In on top (back) +
N Power Out on bottom (front).

Existing devices keep their old ports per design §2.3 — delete +
recreate to pick up the new layout.

Verified on mDock: IOx-8 ports = [(top, Power In, 1), (bottom,
Power Out, 8)].
2026-05-16 11:08:13 +02:00
mAi
f1af2820e1 fix(catalog): migration 006 — IOx-* and Multi-plug-* are power strips
m's actual hardware: IOx-3/6/8 are power strips, not USB hubs. v4 seeded
them as Power × 1 + USB × N which doesn't match reality. Multi-plug 3-6
and Wifi-plug from v5 lumped every Power port on the same bottom edge
without distinguishing input from outputs.

Migration 006 wipes and re-seeds the port profile for all 8
power-distribution types with the canonical 2-row layout:

  Power In  × 1 on top    (back, sort_order 0)
  Power Out × N on bottom (front, sort_order 1)

N for each:
  IOx-3 / Multi-plug 3 → 3
  IOx-6 / Multi-plug 6 → 6
  IOx-8                → 8
  Multi-plug 4         → 4
  Multi-plug 5         → 5
  Wifi-plug            → 1 (pass-through outlet)

Existing device instances keep their already-seeded ports per design
§2.3 (ports are instance-owned). m needs to delete + recreate any
IOx-* / Multi-plug-* / Wifi-plug instances to pick up the new layout.

Tests:
- TestSeed_PortProfiles: comments updated; totals unchanged (Power In 1
  + Power Out N matches old Power 1 + USB N / Power N).
- TestSeed_PowerHubs (was TestSeed_PowerCatalog, rewritten): table-drives
  all 8 affected types. Asserts exactly 2 port rows — top/Power In/1 and
  bottom/Power Out/N — plus kind/icon for the v5 catalog entries.

Design §2.2 catalog table refreshed to match.
2026-05-16 11:03:32 +02:00
mAi
3276cfeb17 merge: port UX — coloured fill + selectable + edge picker
picasso shipped (1 commit @ 82cf5a3, +157/-28):
- onPortPointerDown rewritten into 4 deterministic branches:
  cable-draw-in-progress | no-tool-no-draw | cable-tool | other-tools
  (bubble). Other-tools branch is what makes +Port placement work
  when the click lands on an existing port — the previous handler
  silently returned for any non-cable tool.
- Port circles fill + stroke in cable-type colour. .selected halo.
- New renderInspectorPort: type swatch + label + edge dropdown
  (Top/Right/Bottom/Left) + delete. Edge change PATCHes x_offset
  and y_offset to the chosen side's centre.

End-to-end verified on deployed image via PATCH /ports/:id round-trip.
2026-05-16 02:21:09 +02:00
mAi
82cf5a3052 fix(ui): port UX — coloured fill, selectable, edge picker
Three bundled fixes to slice 7's port flow:

1. Port-pointerdown branches deterministically:
   - cable-draw in progress → finish / cancel
   - no tool, no draw → select port (inspector opens)
   - cable tool → start a draw from this port
   - any other tool armed → bubble (so +Port can place a new port even
     when the click lands on top of an existing one)

2. Port circles now fill *and* stroke with the cable_type colour so the
   port reads as obviously coloured against the device rect. Selection
   adds a drop-shadow halo.

3. Port inspector — clicking a port (no other tool armed) selects it
   and shows a panel with cable-type swatch, label input, edge selector
   (Top / Right / Bottom / Left), and Delete. Changing the edge PATCHes
   x_offset / y_offset to the centre of the chosen side.

snapToDeviceEdge already picks the nearest of the four edges, so
placement on +Port lands correctly without further changes.
2026-05-16 02:15:11 +02:00
mAi
5d055ad521 merge: catalog-power — Multi-plug 3/4/5/6 + Wifi-plug
Migration 005 adds 5 power-distribution device types. Total
device_types now 21.
2026-05-16 02:07:17 +02:00
mAi
93b276875e feat(catalog): migration 005 — power-distribution devices
Adds 5 built-in device_types (project_id NULL, built_in=1):
- Multi-plug 3/4/5/6 (kind=hub, 🔌) — Power × N+1 (1 in + N out)
- Wifi-plug (kind=accessory, 📶) — Power × 2 pass-through outlet

The solver treats every Power port identically regardless of in/out
direction; m knows which end is which from the physical setup.

Tests:
- TestSeed_BuiltInDeviceTypes: built-in count rises from 16 → 21.
- TestSeed_PortProfiles: new entries' port totals.
- TestSeed_PowerCatalog (new, table-driven): asserts kind, icon, and
  the single Power port row for each of the 5 new types.
2026-05-16 02:05:30 +02:00
mAi
205e9eab26 merge: fix mxdrw auth — Bearer → HTTP Basic
mxdrw on mlake uses BASIC_AUTH_USER + BASIC_AUTH_PASS; slice 8's
Bearer design didn't match. Swapped req.SetBasicAuth(MEXDRAW_USER,
MEXDRAW_PASS). DEPLOY-VERIFY drawing on mxdrw confirms end-to-end
export from the deployed image works.
2026-05-16 02:01:16 +02:00
10 changed files with 1270 additions and 119 deletions

View File

@@ -453,18 +453,26 @@ Office setup template:
| 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 |
| IOx-3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| IOx-6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| IOx-8 | hub | Power In × 1 (top/back); Power Out × 8 (bottom/front) |
| **Screen** | display | Power × 1; HDMI × 1 |
| **Keyboard** | accessory | USB × 1 |
| **Mouse** | accessory | USB × 1 |
| **Multi-plug 3** | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| **Multi-plug 4** | hub | Power In × 1 (top/back); Power Out × 4 (bottom/front) |
| **Multi-plug 5** | hub | Power In × 1 (top/back); Power Out × 5 (bottom/front) |
| **Multi-plug 6** | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| **Wifi-plug** | accessory | Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet |
"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.
v5 (migration 005) added the Multi-plug 36 strips and the Wifi-plug
pass-through outlet. v6 (migration 006) re-shaped the IOx-* and
Multi-plug-* profiles to the "1 in on top / N out on bottom" layout —
the IOx-* devices are physical power strips, not USB hubs (m's
hardware), and the Multi-plug-* outputs are now visually distinct from
the input. Convention: `top = back`, `bottom = front`. Existing device
instances keep their already-seeded ports per §2.3 — to pick up the
new layout, delete + re-create the instance.
m can also add **project-custom types** at any time (UI: "+ New device
type" inside the device-create modal) with `project_id = current`.

View File

@@ -17,6 +17,7 @@ func TestSeed_BuiltInDeviceTypes(t *testing.T) {
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
"Screen", "Keyboard", "Mouse",
"Multi-plug 3", "Multi-plug 4", "Multi-plug 5", "Multi-plug 6", "Wifi-plug",
}
if len(got) != len(want) {
t.Fatalf("built-in count = %d, want %d", len(got), len(want))
@@ -54,12 +55,17 @@ func TestSeed_PortProfiles(t *testing.T) {
"fritz": {5}, // Power 1 + RJ45 4
"ChromeCast": {2}, // Power 1 + HDMI 1
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
"IOx-3": {4}, // Power 1 + USB 3
"IOx-6": {7}, // Power 1 + USB 6
"IOx-8": {9}, // Power 1 + USB 8
"Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {1}, // USB 1
"Mouse": {1}, // USB 1
"IOx-3": {4}, // Power In 1 + Power Out 3 (after v6)
"IOx-6": {7}, // Power In 1 + Power Out 6 (after v6)
"IOx-8": {9}, // Power In 1 + Power Out 8 (after v6)
"Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {1}, // USB 1
"Mouse": {1}, // USB 1
"Multi-plug 3": {4}, // Power In 1 + Power Out 3 (after v6)
"Multi-plug 4": {5}, // Power In 1 + Power Out 4 (after v6)
"Multi-plug 5": {6}, // Power In 1 + Power Out 5 (after v6)
"Multi-plug 6": {7}, // Power In 1 + Power Out 6 (after v6)
"Wifi-plug": {2}, // Power In 1 + Power Out 1 (after v6)
}
for name, want := range cases {
dt, ok := byName[name]
@@ -77,6 +83,80 @@ func TestSeed_PortProfiles(t *testing.T) {
}
}
// TestSeed_PowerHubs locks down the post-migration-006 port profile for
// every power-distribution device type: IOx-3/6/8, Multi-plug 3/4/5/6,
// and Wifi-plug. Each carries exactly two profile rows — a single
// "Power In" port on the top (back) edge and N "Power Out" ports on the
// bottom (front) edge, where N is the device-specific output count.
//
// This test covers the v5 catalog identity (kind, icon, built-in) for
// the 5 power-distribution types and the v6 port-profile fix for all
// 8 hubs in one table.
func TestSeed_PowerHubs(t *testing.T) {
s := newTestStore(t)
all, err := s.ListBuiltInDeviceTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 21 {
t.Errorf("built-in count = %d, want 21 (16 from v4 + 5 from v5)", len(all))
}
byName := map[string]DeviceType{}
for _, d := range all {
byName[d.Name] = d
}
cases := []struct {
name string
// kind/icon are only set for the 5 v5-power types; empty means
// "don't check" (the IOx-* keep their v4-seeded kind=hub icon=nil).
kind string
icon string
outCount int // N — number of "Power Out" outlets on the bottom edge
}{
// v5 catalog (kind+icon checked)
{name: "Multi-plug 3", kind: "hub", icon: "🔌", outCount: 3},
{name: "Multi-plug 4", kind: "hub", icon: "🔌", outCount: 4},
{name: "Multi-plug 5", kind: "hub", icon: "🔌", outCount: 5},
{name: "Multi-plug 6", kind: "hub", icon: "🔌", outCount: 6},
{name: "Wifi-plug", kind: "accessory", icon: "📶", outCount: 1},
// v4 hubs re-shaped by v6 (kind/icon left blank → not checked)
{name: "IOx-3", outCount: 3},
{name: "IOx-6", outCount: 6},
{name: "IOx-8", outCount: 8},
}
for _, c := range cases {
dt, ok := byName[c.name]
if !ok {
t.Errorf("missing %q", c.name)
continue
}
if !dt.BuiltIn {
t.Errorf("%s: built_in should be true", c.name)
}
if dt.ProjectID != nil {
t.Errorf("%s: project_id should be nil", c.name)
}
if c.kind != "" && dt.Kind != c.kind {
t.Errorf("%s: kind = %q, want %q", c.name, dt.Kind, c.kind)
}
if c.icon != "" && (dt.Icon == nil || *dt.Icon != c.icon) {
t.Errorf("%s: icon = %v, want %q", c.name, dt.Icon, c.icon)
}
if len(dt.Ports) != 2 {
t.Errorf("%s: expected 2 port-profile rows, got %d", c.name, len(dt.Ports))
continue
}
in := dt.Ports[0]
out := dt.Ports[1]
if in.CableTypeID != 1 || in.Count != 1 || in.Edge != "top" || in.LabelPrefix != "Power In" {
t.Errorf("%s: Power In row mismatch: %+v", c.name, in)
}
if out.CableTypeID != 1 || out.Count != c.outCount || out.Edge != "bottom" || out.LabelPrefix != "Power Out" {
t.Errorf("%s: Power Out row mismatch: %+v (want count=%d)", c.name, out, c.outCount)
}
}
}
// -------------------------------------------------------- CRUD (custom rows)
func TestCreateDeviceType_CustomBasic(t *testing.T) {

View File

@@ -0,0 +1,32 @@
-- mCables v5 — catalog: power-distribution devices.
-- Adds 5 built-in device_types (project_id NULL, built_in=1).
--
-- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The
-- solver treats every Power port identically regardless of in/out
-- direction; m knows which end is which from the physical setup.
--
-- Wifi-plug is a pass-through outlet (Power × 2: one in, one out).
INSERT INTO device_types (name, kind, icon, built_in, description) VALUES
('Multi-plug 3', 'hub', '🔌', 1, '3-way power strip (1 in + 3 out)'),
('Multi-plug 4', 'hub', '🔌', 1, '4-way power strip (1 in + 4 out)'),
('Multi-plug 5', 'hub', '🔌', 1, '5-way power strip (1 in + 5 out)'),
('Multi-plug 6', 'hub', '🔌', 1, '6-way power strip (1 in + 6 out)'),
('Wifi-plug', 'accessory', '📶', 1, 'WiFi-controllable pass-through outlet');
-- Port profiles. cable_types id 1 = Power (seeded in 001).
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 4, 'bottom', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 5, 'bottom', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 6, 'bottom', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 7, 'bottom', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 2, 'bottom', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;

View File

@@ -0,0 +1,87 @@
-- mCables v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles.
--
-- v4 seeded the IOx-3 / IOx-6 / IOx-8 as USB hubs (Power × 1 + USB × N),
-- but m's physical IOx-* devices are power strips (1 power input on
-- the back, N power outputs on the front). v5's Multi-plug 3/4/5/6
-- profiles also lumped every Power port on the bottom edge without
-- distinguishing the input from the outputs.
--
-- This migration replaces the port profile for the 8 power-distribution
-- types with the canonical "1 in (top/back) + N out (bottom/front)"
-- layout. Convention: top=back, bottom=front.
--
-- N for each type:
-- IOx-3 / Multi-plug 3 → 3 outputs
-- IOx-6 → 6 outputs
-- IOx-8 → 8 outputs
-- Multi-plug 4 → 4 outputs
-- Multi-plug 5 → 5 outputs
-- Multi-plug 6 → 6 outputs
-- Wifi-plug → 1 output (it's a pass-through outlet)
--
-- Existing devices m may have created with the old profile keep their
-- already-seeded ports — per design §2.3, ports are instance-owned. To
-- get the new layout on an existing instance, delete it and re-create.
--
-- cable_types id 1 = Power (seeded in 001).
-- 1) Drop the existing port-profile rows for each affected type.
DELETE FROM device_type_ports
WHERE device_type_id IN (
SELECT id FROM device_types
WHERE project_id IS NULL
AND name IN (
'IOx-3', 'IOx-6', 'IOx-8',
'Multi-plug 3', 'Multi-plug 4', 'Multi-plug 5', 'Multi-plug 6',
'Wifi-plug'
)
);
-- 2) Insert the canonical (1 in on top, N out on bottom) profile.
-- IOx-3 — 1 in + 3 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
-- IOx-6 — 1 in + 6 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
-- IOx-8 — 1 in + 8 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 8, 'bottom', 1 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
-- Multi-plug 3 — 1 in + 3 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
-- Multi-plug 4 — 1 in + 4 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 4, 'bottom', 1 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
-- Multi-plug 5 — 1 in + 5 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 5, 'bottom', 1 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
-- Multi-plug 6 — 1 in + 6 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
-- Wifi-plug — 1 in + 1 out (pass-through outlet)
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 1, 'bottom', 1 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;

View File

@@ -191,10 +191,11 @@ type UnsatisfiedReq struct {
// ApplyTemplateResult is the response from POST /apply-template.
type ApplyTemplateResult struct {
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
FramesAdded []Frame `json:"frames_added"`
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
}
type SkippedTemplateDevice struct {

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"strings"
)
@@ -161,6 +162,7 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
}
out := &ApplyTemplateResult{
FramesAdded: []Frame{},
DevicesAdded: []Device{},
RequirementsAdded: []ConnectionRequirement{},
SkippedDevices: []SkippedTemplateDevice{},
@@ -171,8 +173,8 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
opts.OriginX, opts.OriginY = 200, 200
}
// Pull existing device names in the project so we can pre-check
// collisions without aborting the whole transaction.
// Pull existing device + frame names in the project so we can
// pre-check collisions without aborting the whole transaction.
existing, err := s.ListDevices(projectID, nil)
if err != nil {
return nil, err
@@ -181,6 +183,14 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
for _, d := range existing {
nameTaken[d.Name] = true
}
existingFrames, err := s.ListFrames(projectID)
if err != nil {
return nil, err
}
frameNameTaken := map[string]bool{}
for _, f := range existingFrames {
frameNameTaken[f.Name] = true
}
tx, err := s.db.Begin()
if err != nil {
@@ -188,6 +198,37 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
}
defer tx.Rollback()
// Plan a uniform grid for the template's devices inside a new frame
// named after the template. The grid drives both frame size and
// per-device (x, y). Devices that get skipped (name collision /
// SkipDevices) leave their grid cell empty.
const (
devW, devH = 100.0, 35.0
gapX, gapY = 30.0, 50.0
padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1
)
n := len(tmpl.Devices)
cols := 1
if n > 0 {
cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4)
}
rows := 1
if n > 0 {
rows = (n + cols - 1) / cols
}
frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX
frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY
frameName := pickFrameName(tmpl.Name, frameNameTaken)
frame, err := createFrameTx(tx, projectID, FrameCreate{
Name: frameName, X: opts.OriginX, Y: opts.OriginY,
Width: frameW, Height: frameH,
})
if err != nil {
return nil, fmt.Errorf("seed frame %q: %w", frameName, err)
}
out.FramesAdded = append(out.FramesAdded, *frame)
// Map: template_device_id → newly-created device_id (or 0 if skipped).
tmplToDevice := map[int64]int64{}
@@ -215,17 +256,22 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
tmplToDevice[td.ID] = 0
continue
}
// Lay out devices in a horizontal row near the origin, 150 px apart.
x := opts.OriginX + float64(i)*150
y := opts.OriginY
// Use createDeviceTx so the port-seeding share the same transaction.
// Grid cell (col, row) within the frame. Cell anchor is the
// top-left of the device rect; offsets are added to the frame's
// own (x, y) so the device sits inside the frame.
col := i % cols
row := i / cols
x := frame.X + padX + float64(col)*(devW+gapX)
y := frame.Y + padY + float64(row)*(devH+gapY)
// Use createDeviceTx so port-seeding shares the same transaction.
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
Name: name,
TypeID: &td.DeviceTypeID,
FrameID: &frame.ID,
X: x,
Y: y,
Width: 100,
Height: 35,
Width: devW,
Height: devH,
})
if err != nil {
return nil, fmt.Errorf("seed %s: %w", name, err)
@@ -294,6 +340,58 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
return out, nil
}
// pickFrameName returns a frame name that doesn't collide with anything
// in `taken`. Tries the template name first, then "<name> 2", "<name> 3",
// and so on.
func pickFrameName(base string, taken map[string]bool) string {
if !taken[base] {
return base
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s %d", base, i)
if !taken[candidate] {
return candidate
}
}
}
// createFrameTx inserts a frame inside the caller's transaction. Mirrors
// the validation in CreateFrame (name + positive size) but avoids the
// s.db.Exec call so ApplyTemplate can keep everything on the same
// connection under MaxOpenConns(1).
func createFrameTx(tx *sql.Tx, 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)
}
res, err := tx.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()
var out Frame
var ex sql.NullString
err = tx.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(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height,
&ex, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return nil, err
}
if ex.Valid {
out.ExcalidrawID = &ex.String
}
return &out, nil
}
// createDeviceTx is a tx-aware variant of CreateDevice used by
// ApplyTemplate so seeding the template's devices + their ports stays
// inside one atomic apply.

View File

@@ -234,6 +234,76 @@ func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
}
}
func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
frame := res.FramesAdded[0]
if frame.Name != "Living Room" {
t.Errorf("frame name = %q, want %q", frame.Name, "Living Room")
}
for _, d := range res.DevicesAdded {
if d.FrameID == nil || *d.FrameID != frame.ID {
t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID)
}
// Device top-left should be inside the frame rect.
if d.X < frame.X || d.X+d.Width > frame.X+frame.Width {
t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width)
}
if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height {
t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height)
}
}
// No two devices share the same (X, Y) — the grid layout spreads them out.
seen := map[[2]float64]string{}
for _, d := range res.DevicesAdded {
key := [2]float64{d.X, d.Y}
if prev, ok := seen[key]; ok {
t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y)
}
seen[key] = d.Name
}
}
func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Pre-create a frame called "Living Room" so the template's frame name collides.
_, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100})
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
if res.FramesAdded[0].Name != "Living Room 2" {
t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2")
}
}
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")

View File

@@ -23,6 +23,7 @@
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn">Export</button>
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
<span id="toast" class="toast" hidden></span>
</header>
@@ -224,6 +225,23 @@
</form>
</dialog>
<!-- Admin: projects + cable types + device types + setup templates -->
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
<div class="admin-shell">
<header class="admin-header">
<h2 id="adm-title">Admin</h2>
<button type="button" class="btn btn-link admin-close" data-close></button>
</header>
<nav class="admin-tabs" role="tablist">
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
</nav>
<section class="admin-body" id="admin-body" role="tabpanel"></section>
</div>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

View File

@@ -56,14 +56,11 @@ const state = {
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
/** "frame" | "device" | "io" | "req" | "cable" | null */
tool: /** @type {string|null} */ (null),
/** Slice-7 transient state for the +Port tool. */
portToolDevice: /** @type {number|null} */ (null),
portToolTypeID: /** @type {number|null} */ (null),
/** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID: /** @type {number|null} */ (null),
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle", id: number} | null} */ selection: null,
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -88,6 +85,7 @@ 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}`);
@@ -116,6 +114,9 @@ const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body);
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
const createDeviceType = (pid, body) => api("POST", `/projects/${pid}/device-types`, body);
const patchDeviceType = (pid, id, body) => api("PATCH", `/projects/${pid}/device-types/${id}`, body);
const deleteDeviceType = (pid, id) => api("DELETE", `/projects/${pid}/device-types/${id}`);
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body);
@@ -293,10 +294,14 @@ function renderCanvas() {
for (const d of state.devices) {
const g = svgEl("g", { "data-device-id": d.id });
// Stroke = the user-picked colour; fill = a 12% tint of it via
// color-mix so the device "reads" coloured without becoming garish.
// Inline style beats the .device-rect class CSS, which is why CSS
// no longer hard-codes stroke/fill on that class.
const rect = svgEl("rect", {
x: d.x, y: d.y, width: d.width, height: d.height,
class: "device-rect svg-draggable",
stroke: d.color,
style: `stroke: ${d.color}; fill: color-mix(in srgb, ${d.color} 12%, white);`,
rx: 3, ry: 3,
});
if (state.selection?.kind === "device" && state.selection.id === d.id) {
@@ -310,21 +315,26 @@ function renderCanvas() {
g.append(rect, label);
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
// Stroke colour = the cable_type colour the port carries; fill stays white
// so the port reads against any device colour.
// Both fill and stroke = cable_type colour so the port is obviously coloured
// against the device rect.
const ports = portsByDevice.get(d.id) || [];
for (const prt of ports) {
const cx = d.x + prt.x_offset;
const cy = d.y + prt.y_offset;
const color = cableTypeColor.get(prt.type_id) || "#888";
const cls = "port-circle" + (state.cableDrawFromPortID === prt.id ? " cable-from" : "");
const isCableFrom = state.cableDrawFromPortID === prt.id;
const isSelected = state.selection?.kind === "port" && state.selection.id === prt.id;
const cls = "port-circle"
+ (isCableFrom ? " cable-from" : "")
+ (isSelected ? " selected" : "");
const c = svgEl("circle", {
cx, cy, r: 5,
class: cls,
fill: color,
stroke: color,
"data-port-id": prt.id,
});
// Slice 7: port-click drives the manual cable-draw flow.
// Port-click drives both cable-draw (slice 7) and port-select (this fix).
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
g.append(c);
}
@@ -431,6 +441,8 @@ function renderInspector() {
case "cable_type": return renderInspectorCableType(body, state.selection.id);
case "requirement": return renderInspectorRequirement(body, state.selection.id);
case "cable": return renderInspectorCable(body, state.selection.id);
case "port": return renderInspectorPort(body, state.selection.id);
case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -717,13 +729,24 @@ function renderInspectorDevice(body, id) {
});
});
// +Port — arms the port-placement gesture. Active cable type comes
// from the legend selection; if none, defaults to the first cable_type.
// +Port — switch the inspector to the new-port form. m fills in
// type + edge + label and clicks Create; no canvas click required.
body.querySelector("#dev-add-port").addEventListener("click", () => {
if (!state.active) return;
const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
if (!typeID) { alert("Pick a cable type in the legend first"); return; }
armPortTool(d.id, typeID);
state.selection = { kind: "port_new", device_id: d.id };
render();
});
// Clicking a port row in the device's port list selects that port
// and opens its editor in the inspector pane.
body.querySelectorAll(".port-row[data-port-id]").forEach((row) => {
row.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.closest(".port-del")) return;
const pid = Number(row.getAttribute("data-port-id"));
if (!pid) return;
state.selection = { kind: "port", id: pid };
render();
});
});
// Per-port delete.
@@ -993,6 +1016,244 @@ function renderInspectorIO(body, id) {
});
}
// Port editor — type / edge / label / delete. m can also navigate back
// to the device by clicking "back to device" or anywhere on the device.
function renderInspectorPort(body, id) {
const prt = state.ports.find((p) => p.id === id);
if (!prt) { body.innerHTML = ""; return; }
const dev = state.devices.find((d) => d.id === prt.device_id);
if (!dev) { body.innerHTML = ""; return; }
const currentEdge = portEdge(prt, dev);
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-back-device" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Type</span>
<select id="port-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
</div>
`;
body.querySelector("#port-type").value = String(prt.type_id);
body.querySelector("#port-edge").value = currentEdge;
body.querySelector("#port-label").value = prt.label ?? "";
body.querySelector("#port-back-device").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-type").addEventListener("change", async (e) => {
if (!state.active) return;
const newTypeID = Number(/** @type {HTMLSelectElement} */ (e.target).value);
if (newTypeID === prt.type_id) return;
try {
const updated = await patchPort(state.active.id, prt.id, { type_id: newTypeID });
Object.assign(prt, updated);
renderCanvas();
} catch (ex) {
alert(`Type change failed: ${ex.message}`);
}
});
bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
if (!state.active) return;
const updated = await patchPort(state.active.id, prt.id, { label });
Object.assign(prt, updated);
renderCanvas();
});
body.querySelector("#port-edge").addEventListener("change", async (e) => {
if (!state.active) return;
const newEdge = /** @type {HTMLSelectElement} */ (e.target).value;
const oldEdge = portEdge(prt, dev);
if (newEdge === oldEdge) return;
// PATCH to a temp position on the new edge so portEdge() classifies
// this port onto newEdge in the upcoming relayouts. The temp position
// gets overwritten by relayoutEdge(newEdge); the only thing that
// matters is that the port is unambiguously on the right edge.
const tmp = edgeCentre(dev, newEdge);
try {
const updated = await patchPort(state.active.id, prt.id, {
x_offset: tmp.xOff, y_offset: tmp.yOff,
});
Object.assign(prt, updated);
// Re-space both affected edges: the one the port left and the one
// it landed on. Order doesn't matter — they operate on disjoint
// port sets.
await Promise.all([
relayoutEdge(dev.id, oldEdge),
relayoutEdge(dev.id, newEdge),
]);
renderCanvas();
} catch (ex) {
alert(`Move port failed: ${ex.message}`);
}
});
body.querySelector("#port-delete").addEventListener("click", async () => {
if (!state.active) return;
if (!confirm("Delete this port?")) return;
const wasEdge = portEdge(prt, dev);
try {
await deletePort(state.active.id, prt.id);
state.ports = state.ports.filter((p) => p.id !== prt.id);
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables || [];
// Re-space the edge the deleted port was on so the survivors
// shift back to even spacing.
await relayoutEdge(dev.id, wasEdge);
state.selection = null;
render();
} catch (ex) {
alert(`Delete failed: ${ex.message}`);
}
});
}
// Centre of the named edge, expressed as (x_offset, y_offset) relative
// to the device origin. Used as a temp anchor when moving a port between
// edges — the precise centre value is immediately overwritten by
// relayoutEdge, but it has to land on the right edge.
function edgeCentre(dev, edge) {
switch (edge) {
case "top": return { xOff: dev.width / 2, yOff: 0 };
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
case "bottom": return { xOff: dev.width / 2, yOff: dev.height };
case "left": return { xOff: 0, yOff: dev.height / 2 };
default: return { xOff: dev.width / 2, yOff: dev.height };
}
}
// Compute the next available default label for a new port of `typeID`
// on `deviceID`. e.g. if a TV already has "HDMI 1" and "HDMI 2", a new
// HDMI port gets "HDMI 3".
function nextDefaultPortLabel(deviceID, typeID) {
const ct = state.cableTypes.find((t) => t.id === typeID);
const prefix = ct?.name || "Port";
const sibs = state.ports.filter((p) => p.device_id === deviceID && p.type_id === typeID);
let max = 0;
for (const p of sibs) {
const m = (p.label || "").match(new RegExp("^" + escapeRegExp(prefix) + "\\s+(\\d+)$"));
if (m) {
const n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
return `${prefix} ${Math.max(max + 1, sibs.length + 1)}`;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// "Add port" form. Submit → POST → switch inspector to the new port's
// editor. m can cancel back to the device inspector.
function renderInspectorPortNew(body, deviceID) {
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) { body.innerHTML = ""; return; }
if (state.cableTypes.length === 0) {
body.innerHTML = `
<p class="section-title">Add port</p>
<p class="muted">No cable types defined. Add one from the legend first.</p>
<div class="inspector-actions">
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>`;
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
return;
}
const defaultTypeID = state.activeTypeId ?? state.cableTypes[0].id;
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Add port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-new-back" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Type</span>
<select id="port-new-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-new-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom" selected>Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-new-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-primary btn-tiny" id="port-new-create">Create</button>
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>
`;
const typeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-type"));
const edgeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-edge"));
const labelInp = /** @type {HTMLInputElement} */ (body.querySelector("#port-new-label"));
typeSel.value = String(defaultTypeID);
labelInp.value = nextDefaultPortLabel(dev.id, defaultTypeID);
labelInp.placeholder = labelInp.value;
// Recompute default label whenever the type changes (only if m hasn't
// edited the field).
let labelUserEdited = false;
labelInp.addEventListener("input", () => { labelUserEdited = true; });
typeSel.addEventListener("change", () => {
if (labelUserEdited) return;
const tid = Number(typeSel.value);
const next = nextDefaultPortLabel(dev.id, tid);
labelInp.value = next;
labelInp.placeholder = next;
});
body.querySelector("#port-new-back").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-create").addEventListener("click", async () => {
const tid = Number(typeSel.value);
const edge = edgeSel.value;
const label = labelInp.value.trim();
await createPortFromForm(dev.id, tid, edge, label);
});
}
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
@@ -1196,27 +1457,15 @@ function armTool(tool) {
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
wrap.classList.toggle("tool-port", tool === "port");
wrap.classList.toggle("tool-cable", tool === "cable");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
if (tool !== "port") {
state.portToolDevice = null;
state.portToolTypeID = null;
}
if (tool !== "cable") {
state.cableDrawFromPortID = null;
}
}
/** Slice 7: device inspector arms +Port for a specific device + type. */
function armPortTool(deviceID, typeID) {
state.portToolDevice = deviceID;
state.portToolTypeID = typeID;
armTool("port");
}
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
@@ -1268,11 +1517,6 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "port") {
e.preventDefault();
placePortAt(p);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
@@ -1451,49 +1695,117 @@ function openNewDeviceModal(geom) {
};
}
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
* y_off) relative to the device's top-left + a debug-friendly edge name. */
function snapToDeviceEdge(device, x, y) {
// Distance from the point to each of the four edges.
const dxLeft = Math.abs(x - device.x);
const dxRight = Math.abs((device.x + device.width) - x);
const dyTop = Math.abs(y - device.y);
const dyBottom = Math.abs((device.y + device.height) - y);
const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
// Clamp the perpendicular coordinate so the port sits *on* the rect.
const localX = Math.max(0, Math.min(device.width, x - device.x));
const localY = Math.max(0, Math.min(device.height, y - device.y));
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
return { xOff: localX, yOff: device.height, edge: "bottom" };
// Which edge does a given port currently sit on? Snaps the port's
// existing (x_offset, y_offset) to the nearest of the four edges.
function portEdge(port, device) {
const dL = port.x_offset;
const dR = device.width - port.x_offset;
const dT = port.y_offset;
const dB = device.height - port.y_offset;
const min = Math.min(dL, dR, dT, dB);
if (min === dL) return "left";
if (min === dR) return "right";
if (min === dT) return "top";
return "bottom";
}
// Even-spacing layout invariant for ports on a device edge: m wants
// every port lined up on its edge with no overlap. After any change
// to the set of ports on an edge (add / move / delete), recompute the
// offsets so that for N ports they sit at relative positions
// i/(N+1) along the edge for i=1..N.
//
// Sort key preserves m's intent: top/bottom by current x_offset
// (left→right), left/right by current y_offset (top→bottom). For a
// freshly-placed port, that's the click position projected onto the
// edge, so the port keeps its "I dropped it roughly here" rank.
//
// PATCHes only the ports whose offsets actually change, and updates
// state.ports in place. Returns once every PATCH resolves.
async function relayoutEdge(deviceID, edge) {
if (!state.active) return;
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) return;
const isHorizontal = edge === "top" || edge === "bottom";
const axis = isHorizontal ? dev.width : dev.height;
const peers = state.ports
.filter((p) => p.device_id === deviceID && portEdge(p, dev) === edge)
.slice()
.sort((a, b) =>
isHorizontal ? a.x_offset - b.x_offset : a.y_offset - b.y_offset);
const n = peers.length;
if (n === 0) return;
const patches = [];
for (let i = 0; i < n; i++) {
const parallel = axis * (i + 1) / (n + 1);
let xOff, yOff;
switch (edge) {
case "top": xOff = parallel; yOff = 0; break;
case "bottom": xOff = parallel; yOff = dev.height; break;
case "left": xOff = 0; yOff = parallel; break;
case "right": xOff = dev.width; yOff = parallel; break;
}
const p = peers[i];
if (p.x_offset === xOff && p.y_offset === yOff) continue;
p.x_offset = xOff;
p.y_offset = yOff;
patches.push(patchPort(state.active.id, p.id, { x_offset: xOff, y_offset: yOff })
.then((updated) => Object.assign(p, updated)));
}
if (patches.length) {
try {
await Promise.all(patches);
} catch (err) {
alert(`Re-layout failed: ${err.message}`);
}
}
}
/** Port-click flow:
* 1) No source picked yet → this port becomes the source. Highlight it.
* 2) Source already picked → this port is the target. POST a cable
* with `from_port_id` / `to_port_id`, type from the source port,
* auto=0. Shift-click flips the target to "bind to whole device"
* (uses `to_device_id` instead). */
* - A cable draw is in progress (cableDrawFromPortID set):
* same port → cancel; another port → finish the cable.
* - Otherwise, no tool armed:
* select the port (inspector shows edge picker + label + delete).
* - Otherwise, any non-cable tool armed:
* bubble so the canvas-level tool handler runs (lets +Port place
* a new port even when the click lands on an existing one). */
function onPortPointerDown(e, port) {
if (!state.active) return;
if (state.tool && state.tool !== "cable") return; // other tool wins
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID == null) {
// Cable-draw flow takes precedence whenever a source is already picked.
if (state.cableDrawFromPortID != null) {
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID === port.id) {
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
return;
}
// No cable in progress, no tool: select the port → inspector pane.
if (!state.tool) {
e.stopPropagation();
e.preventDefault();
state.selection = { kind: "port", id: port.id };
render();
return;
}
// The cable tool: start a draw from this port.
if (state.tool === "cable") {
e.stopPropagation();
e.preventDefault();
state.cableDrawFromPortID = port.id;
armTool("cable"); // get the crosshair cursor + visual cue
render();
return;
}
if (state.cableDrawFromPortID === port.id) {
// Cancel — clicked the same port again.
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
// Any other tool (port / frame / device / io / req): let the click
// bubble up so the canvas-level branch fires.
}
async function finishCableDrawAt(targetPort, shiftKey) {
@@ -1558,26 +1870,28 @@ async function finishCableDrawAtIO(ioMarker) {
}
}
async function placePortAt(p) {
// Create a port from the sidebar "Add port" form and switch the
// inspector to its editor. Used by renderInspectorPortNew on submit.
async function createPortFromForm(deviceID, typeID, edge, label) {
if (!state.active) return;
const did = state.portToolDevice;
const tid = state.portToolTypeID;
if (did == null || tid == null) { armTool(null); return; }
const dev = state.devices.find((d) => d.id === did);
if (!dev) { armTool(null); return; }
const snap = snapToDeviceEdge(dev, p.x, p.y);
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) return;
const tmp = edgeCentre(dev, edge);
try {
const port = await createPort(state.active.id, did, {
type_id: tid,
x_offset: snap.xOff,
y_offset: snap.yOff,
const port = await createPort(state.active.id, deviceID, {
type_id: typeID,
label: label || undefined,
x_offset: tmp.xOff,
y_offset: tmp.yOff,
});
state.ports.push(port);
armTool(null);
// Re-space every port on this edge so the new one slots into the
// even-spacing grid.
await relayoutEdge(deviceID, edge);
state.selection = { kind: "port", id: port.id };
render();
} catch (e) {
alert(`Add port failed: ${e.message}`);
armTool(null);
}
}
@@ -1697,7 +2011,13 @@ function startDrag(e, kind, id) {
}
}
e.currentTarget.classList.add("dragging");
// Capture the rect element NOW: by the time onUp fires async, the
// browser has nulled out e.currentTarget on the pointerdown event,
// so `e.currentTarget.classList.remove("dragging")` would throw
// "Cannot read properties of null". Sherlock surfaced this from the
// click-only path that pageerror-spammed every device click.
const dragTarget = /** @type {Element} */ (e.currentTarget);
dragTarget.classList.add("dragging");
svg.setPointerCapture(e.pointerId);
let dragged = false;
@@ -1719,7 +2039,7 @@ function startDrag(e, kind, id) {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
e.currentTarget.classList.remove("dragging");
dragTarget.classList.remove("dragging");
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
if (!state.active) return;
@@ -2147,6 +2467,332 @@ async function exportCurrentProject() {
}
}
// ---------- admin modal ---------- //
const adminState = {
activeTab: /** @type {"projects"|"cable-types"|"device-types"|"setup-templates"} */ ("projects"),
};
async function openAdminModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-admin"));
// Always re-fetch the lists when opening so the modal reflects the
// latest server state (m may have edited things from inspector panes
// while the modal was closed).
try {
state.projects = await listProjects();
state.cableTypes = await listCableTypes();
state.setupTemplates = await listSetupTemplates();
} catch (e) {
alert(`Failed to load admin data: ${e.message}`);
return;
}
for (const btn of dlg.querySelectorAll(".admin-tab")) {
btn.addEventListener("click", () => switchAdminTab(btn.getAttribute("data-admin-tab")));
}
switchAdminTab(adminState.activeTab);
dlg.showModal();
}
function switchAdminTab(name) {
adminState.activeTab = name;
for (const btn of $("#modal-admin").querySelectorAll(".admin-tab")) {
const on = btn.getAttribute("data-admin-tab") === name;
btn.setAttribute("aria-selected", on ? "true" : "false");
}
const body = $("#admin-body");
switch (name) {
case "projects": return renderAdminProjects(body);
case "cable-types": return renderAdminCableTypes(body);
case "device-types": return renderAdminDeviceTypes(body);
case "setup-templates": return renderAdminSetupTemplates(body);
}
}
// ---------- admin: projects ---------- //
function renderAdminProjects(body) {
const rows = state.projects.map((p) => `
<div class="admin-row" data-project-id="${p.id}">
<div class="admin-row-title">
<span>${escapeHtml(p.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${p.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(p.name)}" />
</label>
<label class="field"><span>Drawing name</span>
<input class="adm-drawing" type="text" value="${escapeHtml(p.drawing_name)}" />
</label>
<label class="field"><span>Description</span>
<textarea class="adm-desc" rows="2">${escapeHtml(p.description ?? "")}</textarea>
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete…</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No projects.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 12px 0;">
Rename, retitle the drawing, or change the description. Delete cascades all frames /
devices / cables / etc. in the project (cable types are global and unaffected).
</p>
${rows}
`;
for (const row of body.querySelectorAll(".admin-row[data-project-id]")) {
const pid = Number(row.getAttribute("data-project-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const drawing = row.querySelector(".adm-drawing").value.trim();
const desc = row.querySelector(".adm-desc").value;
try {
const updated = await patchProject(pid, {
name, drawing_name: drawing, description: desc,
});
const idx = state.projects.findIndex((p) => p.id === pid);
if (idx >= 0) state.projects[idx] = updated;
if (state.active?.id === pid) state.active = updated;
renderProjectPicker();
switchAdminTab("projects");
} catch (e) {
alert(`Save failed: ${e.message}`);
}
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
const p = state.projects.find((x) => x.id === pid);
if (!p) return;
const typed = prompt(`Type "${p.name}" to confirm delete:`);
if (typed !== p.name) return;
try {
await deleteProject(pid, p.name);
state.projects = state.projects.filter((x) => x.id !== pid);
if (state.active?.id === pid) await activateProject(null);
switchAdminTab("projects");
} catch (e) {
alert(`Delete failed: ${e.message}`);
}
});
}
}
// ---------- admin: cable types ---------- //
function renderAdminCableTypes(body) {
const rows = state.cableTypes.map((t) => `
<div class="admin-row" data-cable-type-id="${t.id}">
<div class="admin-row-title">
<span><span class="swatch" style="background:${t.color}"></span>${escapeHtml(t.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
</label>
<label class="field"><span>Colour</span>
<input class="adm-color" type="color" value="${t.color}" />
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No cable types.</p>`;
body.innerHTML = `
<p class="banner" style="margin:0 0 12px 0;">
Cable types are <strong>global</strong> — renaming or recolouring affects every project.
</p>
${rows}
<div class="admin-add-row">
<div class="admin-row-title"><span>New cable type</span></div>
<label class="field"><span>Name</span>
<input id="adm-ct-new-name" type="text" placeholder="e.g. SATA" />
</label>
<label class="field"><span>Colour</span>
<input id="adm-ct-new-color" type="color" value="#1971c2" />
</label>
<div class="actions">
<button type="button" class="btn btn-primary btn-tiny" id="adm-ct-new-create">+ Add</button>
</div>
</div>
`;
for (const row of body.querySelectorAll(".admin-row[data-cable-type-id]")) {
const id = Number(row.getAttribute("data-cable-type-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const color = row.querySelector(".adm-color").value;
try {
const updated = await patchCableType(id, { name, color });
const idx = state.cableTypes.findIndex((t) => t.id === id);
if (idx >= 0) state.cableTypes[idx] = updated;
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Save failed: ${e.message}`); }
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this cable type? Requires no ports / cables to reference it.")) return;
try {
await deleteCableType(id);
state.cableTypes = state.cableTypes.filter((t) => t.id !== id);
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
body.querySelector("#adm-ct-new-create").addEventListener("click", async () => {
const name = body.querySelector("#adm-ct-new-name").value.trim();
const color = body.querySelector("#adm-ct-new-color").value;
if (!name) { alert("Name required"); return; }
try {
const created = await createCableType({ name, color });
state.cableTypes.push(created);
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Create failed: ${e.message}`); }
});
}
// ---------- admin: device types ---------- //
function renderAdminDeviceTypes(body) {
if (!state.active) {
body.innerHTML = `
<p class="admin-empty">
Pick a project to manage its custom device types. Built-ins are
listed once a project is active (they're project-agnostic but the
catalog read takes a project context).
</p>`;
return;
}
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
const portsLine = (ports) => ports.map((p) =>
`<li><span class="swatch" style="background:${cableTypeColor.get(p.cable_type_id) || "#888"}"></span>` +
`${escapeHtml(cableTypeName.get(p.cable_type_id) || "?")} × ${p.count} <span class="muted">(${escapeHtml(p.edge)})</span></li>`,
).join("");
const builtIns = state.deviceTypes.filter((t) => t.built_in);
const customs = state.deviceTypes.filter((t) => !t.built_in);
const builtRows = builtIns.map((t) => `
<div class="admin-row locked" data-device-type-id="${t.id}">
<div class="admin-row-title">
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}
<span class="muted" style="font-weight:normal;font-size:11px;">· ${escapeHtml(t.kind || "")}</span>
</span>
<span class="locked-badge">built-in</span>
</div>
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
<ul class="port-profile-list">${portsLine(t.ports || [])}</ul>
</div>
`).join("");
const customRows = customs.map((t) => `
<div class="admin-row" data-device-type-id="${t.id}">
<div class="admin-row-title">
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
</label>
<label class="field"><span>Kind</span>
<input class="adm-kind" type="text" value="${escapeHtml(t.kind || "")}" />
</label>
<label class="field"><span>Icon</span>
<input class="adm-icon" type="text" value="${escapeHtml(t.icon || "")}" />
</label>
<label class="field"><span>Description</span>
<input class="adm-desc" type="text" value="${escapeHtml(t.description || "")}" />
</label>
<ul class="port-profile-list">${portsLine(t.ports || []) || '<li class="muted">no port profile</li>'}</ul>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No project-custom types yet.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
Built-in types are seeded by migrations and read-only.
Project-custom types live under the active project ('${escapeHtml(state.active.name)}') and can be edited or deleted.
Port profiles can't be re-shaped here yet — m can still override per device-instance from the device inspector.
</p>
<h3 style="margin:8px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Built-in (${builtIns.length})</h3>
${builtRows}
<h3 style="margin:16px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Project-custom (${customs.length})</h3>
${customRows}
`;
for (const row of body.querySelectorAll(".admin-row:not(.locked)[data-device-type-id]")) {
const id = Number(row.getAttribute("data-device-type-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const kind = row.querySelector(".adm-kind").value.trim();
const icon = row.querySelector(".adm-icon").value.trim();
const desc = row.querySelector(".adm-desc").value;
try {
const updated = await patchDeviceType(state.active.id, id, {
name, kind, icon, description: desc,
});
const idx = state.deviceTypes.findIndex((t) => t.id === id);
if (idx >= 0) state.deviceTypes[idx] = updated;
switchAdminTab("device-types");
} catch (e) { alert(`Save failed: ${e.message}`); }
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this custom device type?")) return;
try {
await deleteDeviceType(state.active.id, id);
state.deviceTypes = state.deviceTypes.filter((t) => t.id !== id);
switchAdminTab("device-types");
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
}
// ---------- admin: setup templates ---------- //
function renderAdminSetupTemplates(body) {
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
const rows = state.setupTemplates.map((t) => {
const dt = (d) => d.device_type?.name ?? `type #${d.device_type_id}`;
const devsById = new Map(t.devices.map((d) => [d.id, d]));
const devsHtml = t.devices.map((d) =>
`<li>${escapeHtml(d.suggested_name ?? dt(d))} <span class="muted">(${escapeHtml(dt(d))})</span></li>`,
).join("") || `<li class="muted">no devices</li>`;
const reqsHtml = t.requirements.map((r) => {
const a = devsById.get(r.from_template_device_id);
const b = devsById.get(r.to_template_device_id);
const an = a ? (a.suggested_name ?? dt(a)) : "?";
const bn = b ? (b.suggested_name ?? dt(b)) : "?";
const ct = r.preferred_cable_type_id != null
? cableTypeName.get(r.preferred_cable_type_id) : null;
const tag = r.must_connect ? "must" : "nice";
return `<li>${escapeHtml(an)}${escapeHtml(bn)} <span class="muted">· ${escapeHtml(ct ?? "solver picks")} · ${tag}</span></li>`;
}).join("") || `<li class="muted">no requirements</li>`;
return `
<div class="admin-row locked" data-template-id="${t.id}">
<div class="admin-row-title">
<span>${escapeHtml(t.name)}</span>
<span class="locked-badge">${t.built_in ? "built-in" : "custom"}</span>
</div>
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
<div class="tmpl-detail">
<strong>Devices (${t.devices.length})</strong>
<ul>${devsHtml}</ul>
</div>
<div class="tmpl-detail">
<strong>Requirements (${t.requirements.length})</strong>
<ul>${reqsHtml}</ul>
</div>
</div>
`;
}).join("") || `<p class="admin-empty">No setup templates.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
Setup templates are stamps for a project — apply one from the header
("Apply template…") to seed a frame + devices + requirements at once.
Built-in templates are read-only.
</p>
${rows}
`;
}
// ---------- boot ---------- //
async function boot() {
@@ -2157,10 +2803,12 @@ async function boot() {
bindCloseButtons($("#modal-requirement"));
bindCloseButtons($("#modal-solve"));
bindCloseButtons($("#modal-template"));
bindCloseButtons($("#modal-admin"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-admin").addEventListener("click", openAdminModal);
$("#btn-add-requirement").addEventListener("click", () => {
if (!state.active) { alert("Pick a project first"); return; }
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }

View File

@@ -183,9 +183,10 @@ body {
pointer-events: none;
}
/* Stroke + fill come from the device's user-set colour, written as
inline style in renderCanvas — leaving them out of .device-rect so
the author CSS doesn't override the inline style. */
.device-rect {
fill: #fff;
stroke: var(--text);
stroke-width: 1.5;
}
.device-rect.selected { stroke-width: 3; }
@@ -214,8 +215,6 @@ body {
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-port #canvas,
.canvas-wrap.tool-port #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
@@ -277,16 +276,17 @@ body {
user-select: none;
}
/* Ports — small circles laid out along the device edge. The fill is
white so the port is visible regardless of the underlying device's
stroke; the stroke colour comes from the cable_type the port carries
(set inline in JS). */
/* Ports — small circles laid out along the device edge. Both fill and
stroke come from the cable_type the port carries (set inline in JS)
so the port reads clearly as a coloured anchor on the device. */
.port-circle {
fill: #fff;
stroke: var(--text);
stroke-width: 2;
cursor: crosshair;
}
.port-circle.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.port-row {
display: grid;
@@ -294,13 +294,20 @@ body {
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 0;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
}
.port-row .swatch {
.port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
margin-right: 6px;
vertical-align: middle;
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }
@@ -369,6 +376,108 @@ body {
/* Solve preview-diff modal */
.modal-wide { width: 560px; }
/* Admin modal — wider, tabbed */
.modal-wide.admin-shell-host { width: 760px; }
#modal-admin { width: 760px; max-width: 90vw; }
.admin-shell { padding: 16px; min-height: 460px; }
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.admin-header h2 { margin: 0; }
.admin-close { font-size: 16px; padding: 4px 8px; }
.admin-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.admin-tab {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 8px 12px;
font: inherit;
color: var(--text-muted);
cursor: pointer;
}
.admin-tab:hover { color: var(--text); }
.admin-tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
}
.admin-body {
font-size: 13px;
max-height: 60vh;
overflow-y: auto;
}
.admin-row {
display: grid;
gap: 6px 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.admin-row:last-child { border-bottom: 0; }
.admin-row .field { display: grid; grid-template-columns: 110px 1fr; align-items: center; }
.admin-row .field span { color: var(--text-muted); font-size: 12px; }
.admin-row .field input,
.admin-row .field textarea,
.admin-row .field select {
width: 100%;
font: inherit;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.admin-row .actions { display: flex; gap: 6px; justify-content: flex-end; }
.admin-row.locked { opacity: 0.85; }
.admin-row .locked-badge {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: var(--surface-2);
color: var(--text-muted);
}
.admin-row-title {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 4px;
}
.admin-row-title .swatch { display: inline-block; }
.admin-empty { color: var(--text-muted); padding: 16px 0; }
.admin-add-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.port-profile-list {
margin: 4px 0 0 0;
padding: 0;
list-style: none;
font-size: 12px;
color: var(--text-muted);
}
.port-profile-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.tmpl-detail {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--text-muted);
}
.tmpl-detail ul { margin: 4px 0 0 16px; padding: 0; }
.sv-body { font-size: 13px; }
.sv-body h3 {
font-size: 11px;