Compare commits
24 Commits
mai/picass
...
mai/sherlo
| Author | SHA1 | Date | |
|---|---|---|---|
| e53f4b1a5e | |||
| 1f246c0047 | |||
| 3a43762e8c | |||
| e12b449169 | |||
| 28a376a7f3 | |||
| 6d637e1fac | |||
| 94869f342e | |||
| a9e6d7aa62 | |||
| b15913124a | |||
| 21bf00566c | |||
| cf1671e8c1 | |||
| d3b660d140 | |||
| dc5fafeaa8 | |||
| 017a77e187 | |||
| 8a31f0af60 | |||
| 98f30306a1 | |||
| 905c75c6db | |||
| c13000ee7e | |||
| 1e3988161b | |||
| 255d52e7c4 | |||
| cd34dde133 | |||
| b6eb29a103 | |||
| e55993ca53 | |||
| 14f0d74e44 |
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# Source-control + worktree noise
|
||||
.git
|
||||
.gitignore
|
||||
.gitea
|
||||
.worktrees
|
||||
|
||||
# mai worker-local logs
|
||||
.m
|
||||
|
||||
# Local runtime state (mounted as a volume in production)
|
||||
data
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Build artefacts
|
||||
bin
|
||||
mcables
|
||||
|
||||
# Editor cruft
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
|
||||
# Documentation (lives in git, not in the image)
|
||||
docs
|
||||
CLAUDE.md
|
||||
README.md
|
||||
|
||||
# Test files (build still respects them via go.mod, this only strips
|
||||
# the test fixtures we might check in later)
|
||||
**/testdata
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Local DB
|
||||
data/*.db
|
||||
data/*.db-wal
|
||||
data/*.db-shm
|
||||
|
||||
# mai worker-local logs (per-worktree, not source)
|
||||
.m/
|
||||
|
||||
# Build artefacts
|
||||
bin/
|
||||
mcables
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# mCables — single-stage build → distroless runtime image.
|
||||
# go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0
|
||||
# and a distroless/static runtime is all we need.
|
||||
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache deps before copying the rest of the source.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# -trimpath strips local paths from the binary; -s -w drops debug info.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/mcables \
|
||||
./cmd/mcables
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/mcables /app/mcables
|
||||
|
||||
ENV MCABLES_ADDR=0.0.0.0:7777 \
|
||||
MCABLES_DB=/app/data/mcables.db
|
||||
|
||||
EXPOSE 7777
|
||||
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
|
||||
# /home/m/stacks/mcables/data is owned by m:m, so the container can write
|
||||
# to it without chowning the host dir. distroless/static-debian12 accepts
|
||||
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
|
||||
USER 1000:1000
|
||||
ENTRYPOINT ["/app/mcables"]
|
||||
27
Makefile
Normal file
27
Makefile
Normal file
@@ -0,0 +1,27 @@
|
||||
.PHONY: build run test typecheck fmt clean
|
||||
|
||||
BIN := bin/mcables
|
||||
PKG := ./...
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables
|
||||
|
||||
run:
|
||||
go run ./cmd/mcables
|
||||
|
||||
test:
|
||||
go test -race $(PKG)
|
||||
|
||||
typecheck:
|
||||
@if [ -f web/tsconfig.json ]; then \
|
||||
cd web && tsc --noEmit; \
|
||||
else \
|
||||
echo "web/tsconfig.json not present yet — typecheck skipped"; \
|
||||
fi
|
||||
|
||||
fmt:
|
||||
gofmt -s -w .
|
||||
|
||||
clean:
|
||||
rm -rf bin
|
||||
168
README.md
168
README.md
@@ -1,37 +1,155 @@
|
||||
# mCables
|
||||
|
||||
Cable management for m's setup — visual interface + SQLite inventory, generating + updating Excalidraw diagrams via mExDraw.
|
||||
Cable-management **framework** for m's setup — visual web editor backed by
|
||||
a single Go binary + SQLite, generating Excalidraw drawings via mExDraw.
|
||||
|
||||
Each cable-managed environment (LOFT, OFFICE, …) is a separate mCables
|
||||
*project*; each project is backed by exactly one `.excalidraw` drawing on
|
||||
mxdrw.msbls.de.
|
||||
|
||||
## Status
|
||||
|
||||
Bootstrap. Architecture sketch below; implementation pending.
|
||||
Slice 1 — bootstrap shipped. Projects + global cable types are
|
||||
end-to-end; the SVG canvas is intentionally empty until slice 2.
|
||||
|
||||
## Goal
|
||||
|
||||
Track devices, ports, and cables across m's setups (server rack, office, living room). Generate / update Excalidraw diagrams from the inventory. Detect bundles of parallel cables. Visualise cable types by colour (RJ45, DP, HDMI, USB, Power, …).
|
||||
|
||||
m's existing drawing is the seed: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw — devices are rectangles, ports are ellipses positioned on the device, cables are arrows from port to port, cable type is encoded via colour with a legend.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
| Layer | Tech | Role |
|
||||
| Slice | What's in it | Status |
|
||||
|---|---|---|
|
||||
| Storage | SQLite (`~/.m/mcables.db`) | `devices`, `ports`, `cables`, `cable_types`, `bundles`, `frames` |
|
||||
| Backend | Go | HTTP API serving the visual frontend, mExDraw integration for diagram I/O |
|
||||
| Frontend | Visual web UI | Browser-based editor (no CLI). Add/edit devices and cables, see live preview |
|
||||
| Output | mExDraw via MCP | Render + update Excalidraw drawings |
|
||||
| Project tracking | mBrian `topic-mcables` | Decisions, status, links to drawings — not the data itself |
|
||||
| 1 | Project CRUD, global cable types, empty SVG canvas, project picker | ✅ |
|
||||
| 2 | Frames + devices, drag-to-position | pending |
|
||||
| 3 | Ports + cables (click-port → click-port) | pending |
|
||||
| 4 | IO markers + cable-type editing | pending |
|
||||
| 5 | Export to mxdrw.msbls.de | pending |
|
||||
|
||||
## Tech decisions (open)
|
||||
## Run it
|
||||
|
||||
- Frontend stack — vanilla TS + small UI lib, or a framework (Svelte / Preact)?
|
||||
- Diagram import from the existing `Cable-Management.excalidraw` — one-shot migration script that parses bindings → DB rows.
|
||||
- Layout algorithm for bundle suggestions — parallel cables along the same path get bundled visually.
|
||||
```sh
|
||||
go run ./cmd/mcables
|
||||
# open http://localhost:7777
|
||||
```
|
||||
|
||||
These get resolved in the first design pass.
|
||||
Or built:
|
||||
|
||||
## Refs
|
||||
```sh
|
||||
make build
|
||||
./bin/mcables
|
||||
```
|
||||
|
||||
- m's seed drawing: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw
|
||||
- mExDraw MCP: `mcp__mexdraw__*`
|
||||
- Related: mBrian `topic-msbls` (infrastructure inventory)
|
||||
The binary serves the frontend from an embedded `web/static/` and the
|
||||
JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
|
||||
|
||||
### Environment
|
||||
|
||||
| Var | Default | Notes |
|
||||
|---|---|---|
|
||||
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
|
||||
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
|
||||
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
|
||||
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
|
||||
|
||||
### Tests
|
||||
|
||||
```sh
|
||||
make test # go test -race ./...
|
||||
```
|
||||
|
||||
Store-level tests cover projects + cable-types CRUD, the
|
||||
`drawing_name` auto-default, the `?confirm=<name>` guardrail on
|
||||
`DELETE /api/projects/:pid`, and the `ON DELETE RESTRICT` on a
|
||||
referenced cable type.
|
||||
|
||||
## API (slice 1)
|
||||
|
||||
```
|
||||
GET /api/healthz → 200 {"status":"ok"}
|
||||
|
||||
GET /api/projects → [Project, …]
|
||||
POST /api/projects ← {name, drawing_name?, description?}
|
||||
drawing_name defaults to "<name>.excalidraw"
|
||||
GET /api/projects/:pid → {project, cable_types, frames, devices, …}
|
||||
PATCH /api/projects/:pid ← partial
|
||||
DELETE /api/projects/:pid?confirm=<name> ← confirm must equal current name
|
||||
|
||||
GET /api/cable-types → [CableType, …] (global)
|
||||
POST /api/cable-types ← {name, color}
|
||||
PATCH /api/cable-types/:id ← partial — affects every project
|
||||
DELETE /api/cable-types/:id ← 409 in_use if any cable references it
|
||||
```
|
||||
|
||||
## Deploy to mDock
|
||||
|
||||
mCables runs on **mDock** at `http://mdock:7777` as a docker-compose
|
||||
service under `/home/m/stacks/mcables/`. Pattern matches the other
|
||||
mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy,
|
||||
no reverse proxy, LAN-trusted.
|
||||
|
||||
### Manual deploy (first roll)
|
||||
|
||||
1. **Build + push the image** (from any host with docker; today the
|
||||
image lives in mAi's Gitea namespace because mAi doesn't have write
|
||||
access to `m/`):
|
||||
|
||||
```sh
|
||||
docker build -t mgit.msbls.de/mai/mcables:latest .
|
||||
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc-mai \
|
||||
| docker login mgit.msbls.de -u mAi --password-stdin
|
||||
docker push mgit.msbls.de/mai/mcables:latest
|
||||
```
|
||||
|
||||
2. **Prepare directories on mDock** (one-time):
|
||||
|
||||
```sh
|
||||
ssh mdock 'mkdir -p /home/m/stacks/mcables/data /home/m/secrets/mcables \
|
||||
&& touch /home/m/secrets/mcables/.env \
|
||||
&& chmod 0600 /home/m/secrets/mcables/.env'
|
||||
scp docker-compose.yml mdock:/home/m/stacks/mcables/docker-compose.yml
|
||||
```
|
||||
|
||||
3. **Pull + start**:
|
||||
|
||||
```sh
|
||||
ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'
|
||||
```
|
||||
|
||||
4. **Verify** from any LAN host:
|
||||
|
||||
```sh
|
||||
curl http://mdock:7777/api/healthz # → {"status":"ok"}
|
||||
curl http://mdock:7777/api/cable-types # → the 5 seeded types
|
||||
```
|
||||
|
||||
To **update** to a new build: rebuild + push the image, then
|
||||
`ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'`.
|
||||
|
||||
### Persistence
|
||||
|
||||
SQLite lives at `/home/m/stacks/mcables/data/mcables.db` on the host
|
||||
(bind-mounted into the container at `/app/data`). Container runs as
|
||||
UID 1000:1000 to align with `m:m` ownership on mDock — DB files end
|
||||
up owned by `m`, the host user.
|
||||
|
||||
`docker compose restart` keeps the data intact (tested 2026-05-15).
|
||||
|
||||
### Automation — follow-up task
|
||||
|
||||
This first roll is **manual**. A Gitea Actions workflow on the
|
||||
self-hosted runner already on mDock (`/home/m/act-runner/`, label
|
||||
`self-hosted:host`) — build → push → `docker compose up -d` on every
|
||||
push to `main` — is a separate task per the design's §10. Tracking
|
||||
spawned by the head if/when wanted.
|
||||
|
||||
## Design + project conventions
|
||||
|
||||
- `docs/design.md` — full v3 design (schema, API, importer/export
|
||||
conventions, slices, mDock deploy notes).
|
||||
- `CLAUDE.md` — project instructions for mai workers.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| DB | SQLite via `modernc.org/sqlite` (cgo-free), WAL, FKs on |
|
||||
| Backend | Go 1.22+ `net/http` ServeMux pattern routing, single binary |
|
||||
| Frontend | Vanilla ES modules + SVG, no build step, embedded via `embed.FS` |
|
||||
| Export (slice 5) | mExDraw HTTP API on mxdrw.msbls.de |
|
||||
|
||||
LAN-trusted, no auth.
|
||||
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# mCables — production compose for mDock.
|
||||
# Lives at /home/m/stacks/mcables/docker-compose.yml on mDock.
|
||||
# Matches the existing mDock service patterns (mgreen, mgeo, …).
|
||||
|
||||
services:
|
||||
mcables:
|
||||
image: mgit.msbls.de/m/mcables:latest
|
||||
container_name: mcables
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7777:7777"
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- MCABLES_ADDR=0.0.0.0:7777
|
||||
- MCABLES_DB=/app/data/mcables.db
|
||||
env_file:
|
||||
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships.
|
||||
- /home/m/secrets/mcables/.env
|
||||
volumes:
|
||||
- /home/m/stacks/mcables/data:/app/data
|
||||
98
docs/sherlock-+dev-bug.md
Normal file
98
docs/sherlock-+dev-bug.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# +Dev click silently drops — root cause & fix
|
||||
|
||||
**Reporter:** sherlock (Playwright debug shift, 2026-05-15 ~23:10)
|
||||
**Target:** http://mdock:7777 (deployed at e12b449)
|
||||
**Verdict:** Bug reproduced; root cause identified; one-line fix verified live.
|
||||
|
||||
## Repro (Playwright, headless Chromium 1217)
|
||||
|
||||
1. GET `http://mdock:7777/?project=1` (LOFT). Snapshot returns one frame (`Entertainment`).
|
||||
2. Click `#tool-device` — armed correctly (`.canvas-wrap.tool-device`, button `.armed`, cursor `crosshair` both over the empty SVG and over frame rects).
|
||||
3. Click inside the frame (rect centre).
|
||||
4. Expected: inline-namer `<foreignObject><input>` appears focused, m types name, Enter, `POST /api/projects/1/devices` fires, device renders.
|
||||
5. Observed: no FO visible, no POST, tool disarms silently. Same failure when clicking on **empty** canvas (outside any frame).
|
||||
|
||||
## Trace (instrumented JS)
|
||||
|
||||
```
|
||||
[782ms] svg.append foreignObject ← placeDeviceAt → promptInline appended it
|
||||
[782ms] focusout BUTTON #tool-device
|
||||
[783ms] focusin INPUT ← input.focus() succeeded
|
||||
[783ms] svg-bubble-pd rect ← pointerdown still bubbling
|
||||
[783ms] mutation.remove foreignObject ← FO ripped out
|
||||
```
|
||||
|
||||
Patched `Element.prototype.remove` traces the offender:
|
||||
|
||||
```
|
||||
at Element.remove (anon)
|
||||
at done (http://mdock:7777/main.js:585:36)
|
||||
at HTMLInputElement.<anonymous> (http://mdock:7777/main.js:592:42)
|
||||
```
|
||||
|
||||
That's `input.addEventListener("blur", () => done(input.value.trim() || null))` in `promptInline`. The input was focused at 783 ms and blurred ~6 ms later, so `done(null)` removed the FO before m could type.
|
||||
|
||||
## Root cause
|
||||
|
||||
`placeDeviceAt` calls `promptInline` synchronously from inside the canvas `pointerdown` handler. `promptInline` does `input.focus()` synchronously. After the pointerdown handler returns, the browser performs the default mousedown action — focus the nearest focusable ancestor of the click target, else **blur the active element**. The click target is an SVG `<rect>` (or the `<svg>` root) which is not focusable, so the freshly-focused input gets blurred. The input's own `blur` listener fires `done(null)` → `fo.remove()`.
|
||||
|
||||
This is independent of the previous fix at 94869f3 (which corrected the ordering of `onCanvasPointerDown` so the tool branches run before the "click on existing element" early-return). Routing is now correct, which is what unmasks the focus-blur bug. `+ Frame` is not affected because there `promptInline` runs from the pointer**up** callback, after pointer events have finished, so no mousedown is pending to steal focus.
|
||||
|
||||
The cursor + Cache-Control fix at 28a376a addressed only the visual cursor lie and the redeploy staleness. It does not touch the click handler.
|
||||
|
||||
## Fix (one line)
|
||||
|
||||
`web/static/main.js` around line 472, in `onCanvasPointerDown`:
|
||||
|
||||
```diff
|
||||
if (state.tool === "frame") {
|
||||
+ e.preventDefault();
|
||||
startFrameRubberBand(e, p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "device") {
|
||||
+ e.preventDefault();
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`e.preventDefault()` on the pointerdown suppresses the compatibility mousedown's default focus-shift, so the input keeps focus and m can type. The `+ Frame` branch gets the same treatment for symmetry and to prevent a subtle text-selection side effect during rubber-band drag (it's not strictly required for the focus issue there because `+ Frame` focuses on pointerup).
|
||||
|
||||
Per-call site rationale, not a blanket `if (state.tool) e.preventDefault()` at the top, because future tools that intentionally rely on default click behavior (e.g. a cable-drawing tool that needs link-clicks on ports) might want different semantics.
|
||||
|
||||
### Verified live
|
||||
|
||||
Monkey-patched the deployed page with a capture-phase listener that calls `e.preventDefault()` while `#tool-device.armed` exists. Result: device created, named `pc-sherlock`, persisted (POST `/api/projects/1/devices` 200). Screenshot: `/tmp/sherlock/fix_verified.png`.
|
||||
|
||||
## Secondary observation (not blocking)
|
||||
|
||||
After the fix, pressing `Enter` on the inline-namer logs a `pageerror`:
|
||||
|
||||
```
|
||||
Failed to execute 'remove' on 'Element':
|
||||
The node to be removed is no longer a child of this node.
|
||||
Perhaps it was moved in a 'blur' event handler?
|
||||
```
|
||||
|
||||
`done()` is called twice (once from Enter keydown, once from the resulting blur). The `if (activeNamer === fo)` guard on the second entry is recursive-safe because the first `done()` runs `fo.remove()` *before* setting `activeNamer = null`, so the synchronous blur fires inside the first remove() and re-enters with the guard still true. Cheap fix: reorder the two lines so the flag clears first:
|
||||
|
||||
```diff
|
||||
const done = (val) => {
|
||||
- if (activeNamer === fo) { fo.remove(); activeNamer = null; }
|
||||
+ if (activeNamer === fo) { activeNamer = null; fo.remove(); }
|
||||
resolve(val);
|
||||
};
|
||||
```
|
||||
|
||||
Functional impact: none — the device is still created. Cosmetic console error only. Worth folding into the same patch.
|
||||
|
||||
## Artifacts (sherlock side, /tmp)
|
||||
|
||||
- `repro.py` — initial reproduction, screenshots before/after click.
|
||||
- `repro2.py` — event-trace instrumentation (capture+bubble pointerdowns, focus, mutations).
|
||||
- `repro3.py` — `remove()` stack-trace capture that named `done() <- blur listener`.
|
||||
- `verify_fix.py` — applied the proposed fix in-page; device created end-to-end.
|
||||
- `test_empty.py` — confirmed the bug bites on empty canvas too, not just inside frames.
|
||||
- `run.log`, `run2.log`, `run3.log`, `verify.log`, `empty.log` — full transcripts.
|
||||
- `fix_verified.png` — post-fix screenshot with `pc-sherlock` device visible inside the frame.
|
||||
144
docs/sherlock-+port-bug.md
Normal file
144
docs/sherlock-+port-bug.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# +Port "still does nothing" — root cause & fix
|
||||
|
||||
**Reporter:** sherlock (Playwright debug shift 2, 2026-05-16 ~11:00)
|
||||
**Target:** http://mdock:7777 (deployed image c361bf38, picasso fix 3276cfe)
|
||||
**Verdict:** +Port is **not actually a no-op** — it successfully creates the port server-side AND client-side every time. The user-visible failure is **invisible stacking**: the new port renders at the exact same pixel as a pre-existing port on the same edge midpoint. m sees no canvas change and no panel switch, so the click feels dead.
|
||||
|
||||
## What I ran
|
||||
|
||||
Five Playwright drives against the live deployment, real Chromium (1217), full console + network + DOM mutation capture. Scripts and transcripts: `/tmp/sherlock/port_*`.
|
||||
|
||||
| Scenario | Click target | POST result | DOM change | What m sees |
|
||||
|---|---|---|---|---|
|
||||
| (a) empty canvas inside frame | (443, 626) screen | 201, port id 40 created | new circle at TV bottom-left | maybe a new dot, but device list still selected, no halo |
|
||||
| (b) on the device body (TV center) | (563, 454) screen | 201, port id 41 | new circle at **(686.4, 643)** — same cx,cy as ports 37, 38, 39 already at that point | nothing — new dot stacks pixel-perfect under existing ones |
|
||||
| (c) directly on an existing port circle | (538, 445) screen | 201, port id 42 | new circle at **(636.4, 643)** — same cx,cy as port 27 | nothing — stacked |
|
||||
|
||||
In every scenario:
|
||||
- `state.tool === "port"` after the +Port click (inferred from `.canvas-wrap.tool-port` + cursor `crosshair`)
|
||||
- `POST /api/projects/1/devices/2/ports` returns 201 with the new port row
|
||||
- `state.ports.push(port)` happens, `render()` redraws, the new circle exists in the DOM
|
||||
- The inspector port list grows by one row
|
||||
- **The canvas does not visibly change.**
|
||||
|
||||
After 7 +Port placements on the TV, the inspector lists 7 ports, but only ~3 dots are visible on the device — the rest are stacked at identical (cx, cy):
|
||||
|
||||
```
|
||||
ports on TV after the test run:
|
||||
port 27 fill=#e03131 cx=636.4 cy=643.0 ← original
|
||||
port 37 fill=#e03131 cx=686.4 cy=643.0
|
||||
port 38 fill=#e03131 cx=686.4 cy=643.0 ← stacks on 37
|
||||
port 39 fill=#1971c2 cx=686.4 cy=643.0 ← stacks on 37,38
|
||||
port 40 fill=#e03131 cx=636.4 cy=678.0
|
||||
port 41 fill=#e03131 cx=686.4 cy=643.0 ← stacks on 37,38,39
|
||||
port 42 fill=#e03131 cx=636.4 cy=643.0 ← stacks on 27
|
||||
```
|
||||
|
||||
## Why it stacks
|
||||
|
||||
`snapToDeviceEdge` (main.js:1560–1574) projects the click to the nearest of the four device edges and clamps the parallel coordinate to `[0, device.width]` (or `device.height`). For the dominant click region — anywhere over the device body — `snapToDeviceEdge` collapses a wide band of clicks down to a handful of discrete `(xOff, yOff)` tuples. For a 100×35 device, clicking anywhere in the bottom half + roughly the horizontal middle resolves to `(xOff=50, yOff=35)`. Two clicks in that band produce two ports at identical coordinates. There is no de-dup against existing ports.
|
||||
|
||||
This is geometry, not a regression — but it means the +Port tool has the same visible signature as a no-op tool for the most natural click region.
|
||||
|
||||
## Why m's panel feedback is also weak
|
||||
|
||||
`placePortAt` (main.js:1685–1706) finishes with `state.ports.push(port); armTool(null); render();`. It does **not** set `state.selection = { kind: "port", id: port.id }`. So:
|
||||
|
||||
- `state.selection` stays on the device (set in the +Port-arming click).
|
||||
- The inspector continues to render the device panel; the port list grows by a row, but the panel doesn't switch.
|
||||
- The new port circle does not get the `.selected` halo (drop-shadow) defined in style.css.
|
||||
|
||||
`placeDeviceAt`, `placeIOMarkerAt`, `startFrameRubberBand`, and `finishCableDrawAt` all set `state.selection` to the new entity. `placePortAt` is the odd one out.
|
||||
|
||||
## Proposed fix (verified live)
|
||||
|
||||
Two changes in `web/static/main.js`, one for stacking, one for feedback. The second is sufficient on its own to make the tool feel responsive.
|
||||
|
||||
### A. Select the newly-placed port (primary)
|
||||
|
||||
```diff
|
||||
async function placePortAt(p) {
|
||||
...
|
||||
try {
|
||||
const port = await createPort(state.active.id, did, {
|
||||
type_id: tid,
|
||||
x_offset: snap.xOff,
|
||||
y_offset: snap.yOff,
|
||||
});
|
||||
state.ports.push(port);
|
||||
+ state.selection = { kind: "port", id: port.id };
|
||||
armTool(null);
|
||||
render();
|
||||
```
|
||||
|
||||
Effect verified live by intercepting the POST response and dispatching a pointerdown on the new port circle (simulates the selection that the fix would set internally). Result:
|
||||
|
||||
- Inspector immediately switches from the **DEVICE** panel to the **PORT** panel showing cable-type swatch, label input, edge dropdown (Top/Right/Bottom/Left), Delete button.
|
||||
- The new port circle gets the `.selected` class → drop-shadow halo, visually distinct even when stacked.
|
||||
- Screenshot: `/tmp/sherlock/port_fix_verified.png`.
|
||||
|
||||
m clicking +Port now produces unambiguous feedback (panel switch + halo), AND he can immediately move the new port to the right edge with the dropdown — exactly the workflow when you wanted "another port over there".
|
||||
|
||||
### B. De-dup snap position (secondary, optional)
|
||||
|
||||
If two ports on the same device land within ~8 px of each other on the same edge, walk along that edge in 12 px increments until a free slot is found (or distribute the existing ones evenly — pick the lighter change). This eliminates pixel-perfect stacks for power users; (A) alone covers the perception bug.
|
||||
|
||||
Sketch:
|
||||
|
||||
```js
|
||||
function snapToDeviceEdge(device, x, y, existingPortsOnDevice) {
|
||||
const raw = snapRaw(device, x, y); // current logic
|
||||
if (!existingPortsOnDevice?.length) return raw;
|
||||
const isOnEdge = (port, edge) =>
|
||||
(edge === "left" && port.x_offset === 0) ||
|
||||
(edge === "right" && port.x_offset === device.width) ||
|
||||
(edge === "top" && port.y_offset === 0) ||
|
||||
(edge === "bottom" && port.y_offset === device.height);
|
||||
const sibs = existingPortsOnDevice.filter((p) => isOnEdge(p, raw.edge));
|
||||
// Walk along the parallel axis until no sibling within 8px tolerance.
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Then `placePortAt` passes `state.ports.filter((p) => p.device_id === did)`.
|
||||
|
||||
Not blocking — m's complaint is fixed by (A). File (B) as a polish ticket.
|
||||
|
||||
## Unrelated bug surfaced during repro (separate fix, separate scope)
|
||||
|
||||
Every click-only device selection throws this pageerror:
|
||||
|
||||
```
|
||||
TypeError: Cannot read properties of null (reading 'classList')
|
||||
at SVGSVGElement.onUp (http://mdock:7777/main.js:1846:21)
|
||||
```
|
||||
|
||||
Source: `startDrag.onUp` does `e.currentTarget.classList.remove("dragging")` (main.js:1846). `e` is the closure-captured pointerdown event; by the time `onUp` runs (async, after pointerup), the browser has nulled out `e.currentTarget`. Click-only path aborts at line 1846 before the trailing `render()` (which is redundant here because `startDrag` already calls render at line 1794, so the user-visible state is fine — but the console error spams every click).
|
||||
|
||||
Trivial fix: capture the target into a closure before defining the async handlers.
|
||||
|
||||
```diff
|
||||
function startDrag(e, kind, id) {
|
||||
...
|
||||
+ const dragTarget = /** @type {Element} */ (e.currentTarget);
|
||||
...
|
||||
- e.currentTarget.classList.add("dragging");
|
||||
+ dragTarget.classList.add("dragging");
|
||||
...
|
||||
const onUp = async (ev) => {
|
||||
...
|
||||
- e.currentTarget.classList.remove("dragging");
|
||||
+ dragTarget.classList.remove("dragging");
|
||||
```
|
||||
|
||||
Not directly responsible for the +Port complaint, but it's noise in every console session and the fix is one line. Worth bundling with the port-select patch.
|
||||
|
||||
## Artifacts
|
||||
|
||||
Under `/tmp/sherlock/`:
|
||||
- `port_repro.py`, `port_repro2.py`, `port_repro3.py` — staged reproductions
|
||||
- `trace_pe.py` — pageerror stack capture
|
||||
- `verify_port_fix.py` — applied the proposed selection-after-placement fix in-page
|
||||
- `port_run.log`, `port_run2.log`, `port_run3.log`, `pe.log`, `verify_port.log` — full transcripts
|
||||
- `port_after_redo.png`, `port_scenario_*.png` — pre-fix screenshots showing the inspector-list-grows-but-canvas-doesn't symptom
|
||||
- `port_fix_verified.png` — post-fix screenshot, inspector switched to **PORT** panel with edge picker
|
||||
99
docs/sherlock-frame-select.md
Normal file
99
docs/sherlock-frame-select.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# "I cannot change frames" — repro & verdict
|
||||
|
||||
**Reporter:** sherlock (Playwright debug shift 3, 2026-05-16 ~19:30)
|
||||
**Target:** http://mdock:7777 (CableGUI, deployed at 79e17a5)
|
||||
**Hypothesis under test:** kandinsky's pan-on-left-drag (2933bb8) regressed frame select because the "isEmpty" selector missed `[data-frame-id]`.
|
||||
|
||||
**Verdict:** **Hypothesis is wrong.** The selector at main.js:2087 already lists `[data-frame-id]`, the frame `<rect>` has its own pointerdown handler that calls `startDrag`, and `startDrag` calls `e.stopPropagation()` synchronously before the pan-start handler could fire. Frame selection works mechanically on the deployed image — verified four ways. m's complaint is real, but it's a **render-order / clutter** problem, not a click-handler regression.
|
||||
|
||||
## What I ran (4 Playwright drives against the deployed image)
|
||||
|
||||
### Selection works on a click that lands on the frame rect
|
||||
|
||||
`frame_repro2.py` TEST 2 — Esc to deselect, then click 2 px inside frame 1's left edge:
|
||||
```
|
||||
before: selected_frames=[] inspector=None
|
||||
after: selected_frames=['1'] inspector='Frame'
|
||||
pointerdowns: svg-capture target=rect.frame-rect frame=1
|
||||
pan changes: []
|
||||
```
|
||||
Frame 1's inspector panel appears with name="Entertainment", x/y/w/h editable, "Delete frame" button. No pan-start fired.
|
||||
|
||||
`frame_clean_test.py` — Esc, then click frame 3 (Network) bottom-left corner where nothing else is rendered:
|
||||
```
|
||||
top element at click point: rect.frame-rect (frame=3)
|
||||
after click: selected_frames=['3'] inspector='Frame'
|
||||
```
|
||||
|
||||
### Selection works on a click + small drag too
|
||||
|
||||
`frame_repro2.py` TEST 3 — 5 px drag (past the 3 px pan threshold) on frame interior:
|
||||
- `startDrag(e, "frame", f.id)` fires at the frame rect's `pointerdown`. It is the **first** pointerdown handler in the chain.
|
||||
- `startDrag` (main.js:2839) calls `e.stopPropagation()` synchronously when no tool is armed → pan-start handler never gets the event.
|
||||
- The 5 px movement is handled by `startDrag`'s own `onMove` (frame drag), not by the empty-canvas pan path.
|
||||
- Result: frame stays selected, frame nudges 5 px to the right. Inspector panel stays correct.
|
||||
|
||||
50 px drag (TEST 4): same — frame stays selected, moves further.
|
||||
|
||||
## Why m experiences "doesn't select"
|
||||
|
||||
The frame `<rect>` paints **first**; everything else paints on top of it in this order (from index.html):
|
||||
|
||||
```
|
||||
canvas-frames ← frame rects (BOTTOM)
|
||||
canvas-devices ← device rects + port circles inside each device <g>
|
||||
canvas-ports
|
||||
canvas-cables ← cable polylines (often crossing through frame interior)
|
||||
canvas-clamps
|
||||
canvas-io ← IO marker diamonds
|
||||
```
|
||||
|
||||
So any click on the frame's visible interior that happens to land on a device, port, port circle, cable polyline, clamp, or IO diamond hits THAT element, not the frame. The frame rect only "wins" the hit-test on pixels that are not covered by anything else.
|
||||
|
||||
Two concrete observations from the LOFT snapshot:
|
||||
|
||||
- Frame 1 "Entertainment" is huge (SVG 1340×848 at viewBox 2000×1500). Most of it renders past the canvas-wrap's right edge (x > 1220 px on a 1500 px viewport) and is occluded by the inspector `<aside>`. Only a ~180 × 424 px strip on the visible canvas right side is selectable. That strip is partly covered by devices and yellow cable polylines crossing diagonally.
|
||||
- Frame 3 "Network": grid hit-test, 9 sample points → 5 hit the frame, 2 hit cable polylines that cross through its centre. `frame_clean_test.py` confirmed: bottom-left corner click selects; centre click selects the cable.
|
||||
|
||||
For m, "I click on the frame and it doesn't select" is **literally true at the click point he's choosing** — but the click is going to the cable/device on top, not the frame underneath. There is no regression in the dispatch.
|
||||
|
||||
## Recommendation (no code change applied)
|
||||
|
||||
Since the click-handler chain is correct, this is a UX call for picasso/perseus to make, not a one-line patch sherlock should apply. Three options ordered by effort:
|
||||
|
||||
1. **Frame label is the selection grip.** Drop `pointer-events: none` from `.frame-label` (style.css:183) and add a pointerdown handler on the label that calls `startDrag(e, "frame", id)`. The label sits in the top-left of the frame, never overlaps a device, and is always visible — m gets a deterministic spot to click. This is a 2-line patch.
|
||||
|
||||
2. **Selection breadcrumb on the device inspector.** When a selected device has `frame_id`, render `parent frame: <name>` as a clickable chip that calls `state.selection = {kind: "frame", id: frame.id}; render()`. Lets m drill from "I selected the wrong thing" → frame in one click.
|
||||
|
||||
3. **Modifier-click to escape to frame.** Alt-click (or Ctrl-click) on any element promotes the hit-test to "select the enclosing frame, if any". Discoverable via tooltip on the inspector header.
|
||||
|
||||
Option (1) is the cheapest and matches user mental model ("click the label to select that thing"). Probably worth pairing with the empty-canvas pan UX so m can hold Shift to disable pan when he wants to clear selection — but that's a separate refinement.
|
||||
|
||||
## What the head's hypothesis *would* look like if it were the bug
|
||||
|
||||
For completeness — if `[data-frame-id]` were missing from the selector in `onCanvasPointerDown`:
|
||||
|
||||
```js
|
||||
// (hypothetical broken version)
|
||||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-io-id]")) return;
|
||||
// ^^ no frame-id
|
||||
if (e.button === 0 && state.cableDrawFromPortID == null) {
|
||||
startEmptyCanvasGesture(e);
|
||||
}
|
||||
```
|
||||
|
||||
…then frame clicks would fall into `startEmptyCanvasGesture`. Sub-3 px clicks would still deselect (no good for m), and any tiny hand-jitter would promote to a pan. m would see "click frame → view shifts, no selection." That symptom matches m's wording — but the actual code is correct, so this isn't what's happening.
|
||||
|
||||
The current code (main.js:2087):
|
||||
```js
|
||||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id], [data-clamp-id], [data-port-id], [data-cable-id]")) return;
|
||||
```
|
||||
All six known target types are listed. No regression here.
|
||||
|
||||
## Artifacts
|
||||
|
||||
Under `/tmp/sherlock/`:
|
||||
- `frame_repro.py`, `frame_repro2.py`, `frame_repro3.py`, `frame_clean_test.py` — staged repros
|
||||
- `canvas_layout.py` — layout dump confirming canvas-wrap vs inspector aside geometry
|
||||
- `frame_run.log`, `frame_run2.log`, `frame_run3.log`, `clean.log`, `layout.log` — full transcripts
|
||||
- `frame2_01_interior.png`, `frame2_02_edge.png`, `frame_clean_result.png` — pre/post-click screenshots showing inspector switching to **Frame** panel on a clean rect-hit click
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -0,0 +1,16 @@
|
||||
module mgit.msbls.de/m/mcables
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.50.1 // indirect
|
||||
)
|
||||
21
go.sum
Normal file
21
go.sum
Normal file
@@ -0,0 +1,21 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
47
internal/db/db.go
Normal file
47
internal/db/db.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Package db owns SQLite access for mCables: migrations runner + the
|
||||
// query layer (store.go). The Store wraps a *sql.DB with helpers; tests
|
||||
// and the HTTP layer take a *Store, never a raw *sql.DB.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Open opens (or creates) the SQLite file at path and returns a Store
|
||||
// with WAL + foreign keys + busy_timeout configured.
|
||||
func Open(path string) (*Store, error) {
|
||||
// `_pragma` query params are honoured by modernc.org/sqlite for
|
||||
// connection-time PRAGMA setup. journal_mode WAL is persistent
|
||||
// across opens; the others apply per-connection.
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)",
|
||||
path,
|
||||
)
|
||||
d, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if err := d.Ping(); err != nil {
|
||||
_ = d.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
// Single writer keeps things deterministic for a local-LAN tool;
|
||||
// reads scale fine in WAL.
|
||||
d.SetMaxOpenConns(1)
|
||||
return &Store{db: d}, nil
|
||||
}
|
||||
|
||||
// Store is the application's handle on the SQLite database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// DB returns the underlying *sql.DB. Used by Migrate and (sparingly) by
|
||||
// callers that need a raw query escape hatch.
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Close releases the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
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)
|
||||
}
|
||||
}
|
||||
94
internal/db/migrate.go
Normal file
94
internal/db/migrate.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// Migrate applies any pending SQL files from migrations/*.sql in
|
||||
// lexicographic order against the given *sql.DB. Applied filenames are
|
||||
// tracked in schema_migrations so each runs at most once. Idempotent.
|
||||
func Migrate(d *sql.DB) error {
|
||||
if _, err := d.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
applied, err := loadApplied(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
if applied[name] {
|
||||
continue
|
||||
}
|
||||
body, err := migrationFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
if err := runMigration(d, name, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadApplied(d *sql.DB) (map[string]bool, error) {
|
||||
rows, err := d.Query("SELECT name FROM schema_migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load applied: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[n] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func runMigration(d *sql.DB, name, body string) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(body); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec("INSERT INTO schema_migrations (name) VALUES (?)", name); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
144
internal/db/migrations/001_init.sql
Normal file
144
internal/db/migrations/001_init.sql
Normal file
@@ -0,0 +1,144 @@
|
||||
-- mCables v3 initial schema. See docs/design.md §2.
|
||||
|
||||
-- A project IS a drawing. LOFT and OFFICE are separate projects.
|
||||
-- One project ↔ one .excalidraw file in mExDraw.
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
drawing_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Cable types: GLOBAL legend, shared across all projects.
|
||||
-- Seeded once below with the 5 defaults.
|
||||
CREATE TABLE cable_types (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE frames (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
x REAL NOT NULL DEFAULT 0,
|
||||
y REAL NOT NULL DEFAULT 0,
|
||||
width REAL NOT NULL DEFAULT 1200,
|
||||
height REAL NOT NULL DEFAULT 800,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, name),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX frames_project_idx ON frames(project_id);
|
||||
|
||||
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,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#1e1e1e',
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
width REAL NOT NULL,
|
||||
height REAL NOT NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, name),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX devices_project_idx ON devices(project_id);
|
||||
CREATE INDEX devices_frame_idx ON devices(frame_id);
|
||||
|
||||
CREATE TABLE ports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
|
||||
label TEXT,
|
||||
x_offset REAL NOT NULL,
|
||||
y_offset REAL NOT NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX ports_project_idx ON ports(project_id);
|
||||
CREATE INDEX ports_device_idx ON ports(device_id);
|
||||
CREATE INDEX ports_type_idx ON ports(type_id);
|
||||
|
||||
CREATE TABLE io_markers (
|
||||
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,
|
||||
label TEXT NOT NULL DEFAULT 'IO',
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX io_markers_project_idx ON io_markers(project_id);
|
||||
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
|
||||
|
||||
CREATE TABLE cables (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
|
||||
label TEXT,
|
||||
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
|
||||
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
|
||||
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
|
||||
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK (
|
||||
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
|
||||
),
|
||||
CHECK (
|
||||
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
|
||||
),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX cables_project_idx ON cables(project_id);
|
||||
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
|
||||
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
|
||||
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
|
||||
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
|
||||
CREATE INDEX cables_type_idx ON cables(type_id);
|
||||
|
||||
CREATE TABLE bundles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
auto INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
CREATE INDEX bundles_project_idx ON bundles(project_id);
|
||||
|
||||
CREATE TABLE bundle_cables (
|
||||
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
|
||||
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (bundle_id, cable_id)
|
||||
);
|
||||
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
|
||||
|
||||
-- Seed the 5 default cable types, once.
|
||||
INSERT INTO cable_types (name, color) VALUES
|
||||
('Power', '#e03131'),
|
||||
('USB', '#2f9e44'),
|
||||
('HDMI', '#1971c2'),
|
||||
('DP', '#9c36b5'),
|
||||
('RJ45', '#ffd500');
|
||||
64
internal/db/models.go
Normal file
64
internal/db/models.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package db
|
||||
|
||||
// Project is the top-level entity. One project ↔ one .excalidraw drawing.
|
||||
type Project struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DrawingName string `json:"drawing_name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CableType is global. Renaming/recolouring affects every project.
|
||||
type CableType struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
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.
|
||||
// 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 []Frame `json:"frames"`
|
||||
Devices []Device `json:"devices"`
|
||||
Ports []any `json:"ports"`
|
||||
Cables []any `json:"cables"`
|
||||
IOMarkers []any `json:"io_markers"`
|
||||
Bundles []any `json:"bundles"`
|
||||
CableTypes []CableType `json:"cable_types"`
|
||||
}
|
||||
330
internal/db/store.go
Normal file
330
internal/db/store.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sentinel errors callers can match against. The server layer maps these
|
||||
// to HTTP status codes.
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict") // UNIQUE violation
|
||||
ErrInUse = errors.New("in use") // cable_type referenced by a cable
|
||||
ErrConfirmName = errors.New("confirm name missing or mismatched")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Projects
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// CreateProject inserts a new project. drawingName, if empty, defaults to
|
||||
// "<name>.excalidraw". name and drawingName are trimmed; an empty name
|
||||
// after trimming is rejected.
|
||||
func (s *Store) CreateProject(name, drawingName, description string) (*Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
drawingName = strings.TrimSpace(drawingName)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if drawingName == "" {
|
||||
drawingName = name + ".excalidraw"
|
||||
}
|
||||
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO projects (name, drawing_name, description) VALUES (?, ?, ?)`,
|
||||
name, drawingName, description,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetProject(id)
|
||||
}
|
||||
|
||||
// GetProject loads a project by ID.
|
||||
func (s *Store) GetProject(id int64) (*Project, error) {
|
||||
var p Project
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, drawing_name, description, created_at, updated_at
|
||||
FROM projects WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListProjects returns every project ordered by name.
|
||||
func (s *Store) ListProjects() ([]Project, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, drawing_name, description, created_at, updated_at
|
||||
FROM projects ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Project
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ProjectUpdate carries partial fields for PATCH. A nil pointer means
|
||||
// "leave this field untouched".
|
||||
type ProjectUpdate struct {
|
||||
Name *string
|
||||
DrawingName *string
|
||||
Description *string
|
||||
}
|
||||
|
||||
// UpdateProject applies the partial update. Empty struct = no-op (just
|
||||
// bumps updated_at). Empty Name (after trim) is rejected; whitespace-only
|
||||
// DrawingName is treated as "use <name>.excalidraw" — same default as
|
||||
// CreateProject.
|
||||
func (s *Store) UpdateProject(id int64, u ProjectUpdate) (*Project, error) {
|
||||
cur, err := s.GetProject(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.DrawingName != nil {
|
||||
v := strings.TrimSpace(*u.DrawingName)
|
||||
if v == "" {
|
||||
v = cur.Name + ".excalidraw"
|
||||
}
|
||||
cur.DrawingName = v
|
||||
}
|
||||
if u.Description != nil {
|
||||
cur.Description = *u.Description
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE projects
|
||||
SET name = ?, drawing_name = ?, description = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
cur.Name, cur.DrawingName, cur.Description, id,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetProject(id)
|
||||
}
|
||||
|
||||
// DeleteProject removes the project (cascading frames, devices, ports,
|
||||
// cables, io_markers, bundles, bundle_cables). confirmName must match the
|
||||
// project's current name; otherwise ErrConfirmName is returned and nothing
|
||||
// is deleted.
|
||||
func (s *Store) DeleteProject(id int64, confirmName string) error {
|
||||
p, err := s.GetProject(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if confirmName != p.Name {
|
||||
return ErrConfirmName
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
types, err := s.ListCableTypes()
|
||||
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: frames,
|
||||
Devices: devices,
|
||||
Ports: []any{},
|
||||
Cables: []any{},
|
||||
IOMarkers: []any{},
|
||||
Bundles: []any{},
|
||||
CableTypes: types,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Cable types (global)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// CreateCableType inserts a global cable type. name must be globally unique.
|
||||
func (s *Store) CreateCableType(name, color string) (*CableType, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
color = strings.TrimSpace(color)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if color == "" {
|
||||
return nil, fmt.Errorf("%w: color is required", ErrInvalidInput)
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO cable_types (name, color) VALUES (?, ?)`, name, color,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetCableType(id)
|
||||
}
|
||||
|
||||
// GetCableType loads a cable type by ID.
|
||||
func (s *Store) GetCableType(id int64) (*CableType, error) {
|
||||
var t CableType
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, color, created_at, updated_at
|
||||
FROM cable_types WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ListCableTypes returns every cable type ordered by id (insertion order,
|
||||
// so the legend renders in the same order across reloads).
|
||||
func (s *Store) ListCableTypes() ([]CableType, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, color, created_at, updated_at
|
||||
FROM cable_types ORDER BY id`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CableType{}
|
||||
for rows.Next() {
|
||||
var t CableType
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CableTypeUpdate is the partial-update shape for PATCH.
|
||||
type CableTypeUpdate struct {
|
||||
Name *string
|
||||
Color *string
|
||||
}
|
||||
|
||||
// UpdateCableType applies a partial update.
|
||||
func (s *Store) UpdateCableType(id int64, u CableTypeUpdate) (*CableType, error) {
|
||||
cur, err := s.GetCableType(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 _, err := s.db.Exec(
|
||||
`UPDATE cable_types
|
||||
SET name = ?, color = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
cur.Name, cur.Color, id,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetCableType(id)
|
||||
}
|
||||
|
||||
// DeleteCableType removes a cable type. SQLite enforces ON DELETE RESTRICT
|
||||
// from cables.type_id and ports.type_id; we surface that as ErrInUse plus
|
||||
// the count of referencing cables (so the UI can show "blocked by N cables").
|
||||
func (s *Store) DeleteCableType(id int64) error {
|
||||
if _, err := s.GetCableType(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM cable_types WHERE id = ?`, id); err != nil {
|
||||
if isForeignKeyConstraint(err) {
|
||||
return ErrInUse
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountCablesUsingType returns how many cables reference this cable_type.
|
||||
// Used by the server to enrich a 409 InUse response with a helpful number.
|
||||
func (s *Store) CountCablesUsingType(id int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM cables WHERE type_id = ?`, id).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Error mapping
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// mapWriteErr classifies SQLite write errors into our sentinel errors so
|
||||
// the handler layer can pick the right HTTP status. Falls through to the
|
||||
// raw error for anything we don't recognise.
|
||||
func mapWriteErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "UNIQUE constraint failed"):
|
||||
return fmt.Errorf("%w: %s", ErrConflict, msg)
|
||||
case strings.Contains(msg, "FOREIGN KEY constraint failed"):
|
||||
return fmt.Errorf("%w: %s", ErrInUse, msg)
|
||||
case strings.Contains(msg, "CHECK constraint failed"):
|
||||
return fmt.Errorf("%w: %s", ErrInvalidInput, msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isForeignKeyConstraint(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed")
|
||||
}
|
||||
281
internal/db/store_test.go
Normal file
281
internal/db/store_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
if err := Migrate(s.DB()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- projects
|
||||
|
||||
func TestCreateProject_DefaultsDrawingName(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, err := s.CreateProject("LOFT", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if p.Name != "LOFT" {
|
||||
t.Errorf("name = %q, want LOFT", p.Name)
|
||||
}
|
||||
if p.DrawingName != "LOFT.excalidraw" {
|
||||
t.Errorf("drawing_name = %q, want LOFT.excalidraw", p.DrawingName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProject_AcceptsExplicitDrawingName(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, err := s.CreateProject("OFFICE", "office-rack.excalidraw", "rack only")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if p.DrawingName != "office-rack.excalidraw" {
|
||||
t.Errorf("drawing_name = %q, want office-rack.excalidraw", p.DrawingName)
|
||||
}
|
||||
if p.Description != "rack only" {
|
||||
t.Errorf("description = %q", p.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProject_EmptyNameRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.CreateProject(" ", "", ""); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProject_DuplicateNameRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.CreateProject("LOFT", "", ""); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
if _, err := s.CreateProject("LOFT", "", ""); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("second create err = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjects_OrderedByName(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
for _, name := range []string{"OFFICE", "LOFT", "GARAGE"} {
|
||||
if _, err := s.CreateProject(name, "", ""); err != nil {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
got, err := s.ListProjects()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
want := []string{"GARAGE", "LOFT", "OFFICE"}
|
||||
for i, p := range got {
|
||||
if p.Name != want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, p.Name, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProject_NotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.GetProject(999); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProject_PartialFields(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
newName := "LOFT-2"
|
||||
updated, err := s.UpdateProject(p.ID, ProjectUpdate{Name: &newName})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.Name != "LOFT-2" {
|
||||
t.Errorf("name = %q, want LOFT-2", updated.Name)
|
||||
}
|
||||
// drawing_name should not auto-change from a Name update — it's only
|
||||
// auto-defaulted when drawing_name is explicitly set to empty.
|
||||
if updated.DrawingName != "LOFT.excalidraw" {
|
||||
t.Errorf("drawing_name = %q, want LOFT.excalidraw (unchanged)", updated.DrawingName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProject_BlankDrawingNameDefaults(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "old.excalidraw", "")
|
||||
blank := " "
|
||||
updated, err := s.UpdateProject(p.ID, ProjectUpdate{DrawingName: &blank})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.DrawingName != "LOFT.excalidraw" {
|
||||
t.Errorf("drawing_name = %q, want LOFT.excalidraw", updated.DrawingName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProject_ConfirmGuardrail(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
|
||||
// Wrong name → no delete.
|
||||
if err := s.DeleteProject(p.ID, "OFFICE"); !errors.Is(err, ErrConfirmName) {
|
||||
t.Fatalf("wrong-name err = %v, want ErrConfirmName", err)
|
||||
}
|
||||
if _, err := s.GetProject(p.ID); err != nil {
|
||||
t.Fatalf("project should still exist: %v", err)
|
||||
}
|
||||
|
||||
// Empty confirm → no delete.
|
||||
if err := s.DeleteProject(p.ID, ""); !errors.Is(err, ErrConfirmName) {
|
||||
t.Fatalf("empty-confirm err = %v, want ErrConfirmName", err)
|
||||
}
|
||||
|
||||
// Correct name → delete.
|
||||
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
|
||||
t.Fatalf("correct-name delete: %v", err)
|
||||
}
|
||||
if _, err := s.GetProject(p.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("project should be gone: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_IncludesGlobalCableTypes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
snap, err := s.Snapshot(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if snap.Project.ID != p.ID {
|
||||
t.Errorf("project.id = %d, want %d", snap.Project.ID, p.ID)
|
||||
}
|
||||
if len(snap.CableTypes) != 5 {
|
||||
t.Errorf("cable_types len = %d, want 5 (the seeded defaults)", len(snap.CableTypes))
|
||||
}
|
||||
if snap.Frames == nil || snap.Devices == nil || snap.Ports == nil ||
|
||||
snap.Cables == nil || snap.IOMarkers == nil || snap.Bundles == nil {
|
||||
t.Errorf("snapshot collections must be non-nil arrays, not null, for slice-1 JSON output")
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ cable_types
|
||||
|
||||
func TestListCableTypes_SeededFive(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ts, err := s.ListCableTypes()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
wantNames := []string{"Power", "USB", "HDMI", "DP", "RJ45"}
|
||||
if len(ts) != 5 {
|
||||
t.Fatalf("len = %d, want 5", len(ts))
|
||||
}
|
||||
for i, want := range wantNames {
|
||||
if ts[i].Name != want {
|
||||
t.Errorf("[%d].Name = %q, want %q", i, ts[i].Name, want)
|
||||
}
|
||||
if ts[i].Color == "" {
|
||||
t.Errorf("[%d].Color empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCableType_GlobalUnique(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.CreateCableType("Audio", "#ff0000"); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := s.CreateCableType("Audio", "#00ff00"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("dup err = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCableType_RenameAndRecolour(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ts, _ := s.ListCableTypes()
|
||||
hdmi := ts[2] // seed order: Power, USB, HDMI, DP, RJ45
|
||||
newName := "HDMI-2.1"
|
||||
newColor := "#000000"
|
||||
updated, err := s.UpdateCableType(hdmi.ID, CableTypeUpdate{Name: &newName, Color: &newColor})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.Name != "HDMI-2.1" || updated.Color != "#000000" {
|
||||
t.Errorf("got %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCableType_BlockedByCable(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
|
||||
// Reach the seeded Power cable type.
|
||||
ts, _ := s.ListCableTypes()
|
||||
power := ts[0]
|
||||
|
||||
// Wire up a minimal cable referencing the Power type via the raw DB
|
||||
// (the typed device/port API ships in slice 2+). The schema CHECK
|
||||
// requires exactly one endpoint each side — use device-level binding
|
||||
// against placeholder rows.
|
||||
d := s.DB()
|
||||
res, err := d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
|
||||
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderA")
|
||||
if err != nil {
|
||||
t.Fatalf("insert device A: %v", err)
|
||||
}
|
||||
deviceA, _ := res.LastInsertId()
|
||||
res, err = d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
|
||||
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderB")
|
||||
if err != nil {
|
||||
t.Fatalf("insert device B: %v", err)
|
||||
}
|
||||
deviceB, _ := res.LastInsertId()
|
||||
if _, err := d.Exec(`INSERT INTO cables
|
||||
(project_id, type_id, from_device_id, to_device_id)
|
||||
VALUES (?, ?, ?, ?)`, p.ID, power.ID, deviceA, deviceB); err != nil {
|
||||
t.Fatalf("insert cable: %v", err)
|
||||
}
|
||||
|
||||
// Now delete → must be blocked.
|
||||
if err := s.DeleteCableType(power.ID); !errors.Is(err, ErrInUse) {
|
||||
t.Fatalf("delete err = %v, want ErrInUse", err)
|
||||
}
|
||||
n, err := s.CountCablesUsingType(power.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCableType_UnusedSucceeds(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
t2, _ := s.CreateCableType("Audio", "#000000")
|
||||
if err := s.DeleteCableType(t2.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProject_DoesNotTouchCableTypes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
ts, _ := s.ListCableTypes()
|
||||
if len(ts) != 5 {
|
||||
t.Errorf("cable_types should survive project deletion; got %d, want 5", len(ts))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
236
internal/server/handlers.go
Normal file
236
internal/server/handlers.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
type handlers struct {
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- utility
|
||||
|
||||
// writeJSON encodes v as JSON at the given status. Errors during encoding
|
||||
// are logged-silent (the response has already started) — this is the
|
||||
// last-resort path; callers should validate inputs early.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// writeError maps a Store sentinel to an HTTP status + JSON body.
|
||||
func writeError(w http.ResponseWriter, err error, details any) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
writeJSON(w, http.StatusNotFound, errorBody{Error: err.Error(), Details: details})
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
|
||||
case errors.Is(err, db.ErrInUse):
|
||||
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
|
||||
case errors.Is(err, db.ErrConfirmName):
|
||||
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
|
||||
case errors.Is(err, db.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details})
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64Path(r *http.Request, key string) (int64, bool) {
|
||||
raw := r.PathValue(key)
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- health
|
||||
|
||||
func (h *handlers) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- projects
|
||||
|
||||
type projectCreate struct {
|
||||
Name string `json:"name"`
|
||||
DrawingName string `json:"drawing_name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type projectPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
DrawingName *string `json:"drawing_name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listProjects(w http.ResponseWriter, _ *http.Request) {
|
||||
ps, err := h.store.ListProjects()
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
if ps == nil {
|
||||
ps = []db.Project{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ps)
|
||||
}
|
||||
|
||||
func (h *handlers) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
var body projectCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
p, err := h.store.CreateProject(body.Name, body.DrawingName, body.Description)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (h *handlers) getProject(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
snap, err := h.store.Snapshot(id)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
func (h *handlers) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body projectPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
p, err := h.store.UpdateProject(id, db.ProjectUpdate{
|
||||
Name: body.Name,
|
||||
DrawingName: body.DrawingName,
|
||||
Description: body.Description,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
confirm := r.URL.Query().Get("confirm")
|
||||
if confirm == "" {
|
||||
writeError(w, db.ErrConfirmName,
|
||||
"DELETE requires ?confirm=<project name> matching the project's current name")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteProject(id, confirm); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cable_types
|
||||
|
||||
type cableTypeCreate struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type cableTypePatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listCableTypes(w http.ResponseWriter, _ *http.Request) {
|
||||
ts, err := h.store.ListCableTypes()
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ts)
|
||||
}
|
||||
|
||||
func (h *handlers) createCableType(w http.ResponseWriter, r *http.Request) {
|
||||
var body cableTypeCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
t, err := h.store.CreateCableType(body.Name, body.Color)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (h *handlers) patchCableType(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cableTypePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
t, err := h.store.UpdateCableType(id, db.CableTypeUpdate{
|
||||
Name: body.Name,
|
||||
Color: body.Color,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteCableType(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteCableType(id); err != nil {
|
||||
// On ErrInUse, count referencing cables so the client can show
|
||||
// "blocked by N cables".
|
||||
if errors.Is(err, db.ErrInUse) {
|
||||
n, _ := h.store.CountCablesUsingType(id)
|
||||
writeError(w, err, map[string]int{"in_use_by_cables": n})
|
||||
return
|
||||
}
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
71
internal/server/server.go
Normal file
71
internal/server/server.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package server wires the HTTP API + the embedded frontend onto a
|
||||
// single net/http handler. Routes use Go 1.22 ServeMux pattern matching
|
||||
// (no router framework).
|
||||
package server
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
// New returns an http.Handler serving the mCables API at /api/ and the
|
||||
// embedded frontend at /. The frontend FS should be rooted such that
|
||||
// "index.html" is at its root.
|
||||
func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
h := &handlers{store: store}
|
||||
|
||||
// Health
|
||||
mux.HandleFunc("GET /api/healthz", h.healthz)
|
||||
|
||||
// Projects
|
||||
mux.HandleFunc("GET /api/projects", h.listProjects)
|
||||
mux.HandleFunc("POST /api/projects", h.createProject)
|
||||
mux.HandleFunc("GET /api/projects/{pid}", h.getProject)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}", h.patchProject)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}", h.deleteProject)
|
||||
|
||||
// Cable types (global)
|
||||
mux.HandleFunc("GET /api/cable-types", h.listCableTypes)
|
||||
mux.HandleFunc("POST /api/cable-types", h.createCableType)
|
||||
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.
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
137
web/static/index.html
Normal file
137
web/static/index.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>mCables</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">mCables</span>
|
||||
<div class="project-picker">
|
||||
<label for="project-select" class="sr-only">Project</label>
|
||||
<select id="project-select" aria-label="Active project">
|
||||
<option value="">— no project —</option>
|
||||
</select>
|
||||
<button type="button" id="btn-new-project" class="btn">+ Project</button>
|
||||
<button type="button" id="btn-delete-project" class="btn btn-danger" hidden>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
|
||||
Export
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="sidebar" aria-label="Tools">
|
||||
<section class="legend">
|
||||
<h2 class="sidebar-heading">Cable types</h2>
|
||||
<ul id="legend-list" class="legend-list"></ul>
|
||||
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
|
||||
</section>
|
||||
<section class="tools">
|
||||
<h2 class="sidebar-heading">Tools</h2>
|
||||
<ul class="tool-list">
|
||||
<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>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-wrap" aria-label="Diagram">
|
||||
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
|
||||
<g id="canvas-frames"></g>
|
||||
<g id="canvas-devices"></g>
|
||||
<g id="canvas-ports"></g>
|
||||
<g id="canvas-cables"></g>
|
||||
<g id="canvas-io"></g>
|
||||
</svg>
|
||||
<p id="empty-hint" class="empty-hint">
|
||||
Pick or create a project to start drawing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<aside class="inspector" aria-label="Inspector">
|
||||
<h2 class="sidebar-heading">Inspector</h2>
|
||||
<div id="inspector-body">
|
||||
<p class="muted">Nothing selected.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- New Project modal -->
|
||||
<dialog id="modal-new-project" class="modal" aria-labelledby="np-title">
|
||||
<form method="dialog" id="form-new-project">
|
||||
<h2 id="np-title">New project</h2>
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" required autocomplete="off" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Drawing name</span>
|
||||
<input type="text" name="drawing_name" autocomplete="off"
|
||||
placeholder="auto: <name>.excalidraw" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Description</span>
|
||||
<textarea name="description" rows="2"></textarea>
|
||||
</label>
|
||||
<p class="form-error" id="np-error" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- New/Edit Cable Type modal -->
|
||||
<dialog id="modal-cable-type" class="modal" aria-labelledby="ct-title">
|
||||
<form method="dialog" id="form-cable-type">
|
||||
<h2 id="ct-title">Cable type</h2>
|
||||
<p class="banner">
|
||||
Cable types are shared across all projects. Renaming or recolouring
|
||||
affects every project.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" required autocomplete="off" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Colour</span>
|
||||
<input type="color" name="color" value="#1971c2" />
|
||||
</label>
|
||||
<p class="form-error" id="ct-error" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Project confirm -->
|
||||
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
|
||||
<form method="dialog" id="form-delete-project">
|
||||
<h2 id="dp-title">Delete project</h2>
|
||||
<p>
|
||||
This will cascade-delete every frame, device, port, cable, IO marker
|
||||
and bundle in the project. <strong>Cable types are global and are not affected.</strong>
|
||||
</p>
|
||||
<p>Type the project name to confirm:</p>
|
||||
<input type="text" name="confirm" required autocomplete="off"
|
||||
id="dp-confirm-input" />
|
||||
<p class="form-error" id="dp-error" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
849
web/static/main.js
Normal file
849
web/static/main.js
Normal file
@@ -0,0 +1,849 @@
|
||||
// mCables frontend entry — vanilla ES module, no build step.
|
||||
//
|
||||
// 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,
|
||||
/** @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 ---------- //
|
||||
|
||||
async function api(method, path, body) {
|
||||
const res = await fetch(API + path, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = new Error(json?.error || res.statusText);
|
||||
err.status = res.status;
|
||||
err.details = json?.details;
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
const listProjects = () => api("GET", "/projects");
|
||||
const createProject = (body) => api("POST", "/projects", body);
|
||||
const deleteProject = (id, confirm) =>
|
||||
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
|
||||
const getSnapshot = (id) => api("GET", `/projects/${id}`);
|
||||
|
||||
const listCableTypes = () => api("GET", "/cable-types");
|
||||
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));
|
||||
|
||||
function setHidden(el, hidden) {
|
||||
if (hidden) el.setAttribute("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() {
|
||||
const raw = new URLSearchParams(location.search).get("project");
|
||||
const id = raw && Number.parseInt(raw, 10);
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function setActiveInURL(id) {
|
||||
const url = new URL(location.href);
|
||||
if (id == null) url.searchParams.delete("project");
|
||||
else url.searchParams.set("project", String(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 = "";
|
||||
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;
|
||||
sel.append(opt);
|
||||
}
|
||||
setHidden($("#btn-delete-project"), !state.active);
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
const ul = $("#legend-list");
|
||||
ul.innerHTML = "";
|
||||
for (const t of state.cableTypes) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "legend-row";
|
||||
li.dataset.id = String(t.id);
|
||||
if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
|
||||
li.innerHTML = `
|
||||
<span class="legend-swatch" style="background:${t.color}"></span>
|
||||
<span class="legend-name"></span>
|
||||
<button type="button" class="legend-edit" aria-label="Edit cable type">edit</button>
|
||||
`;
|
||||
li.querySelector(".legend-name").textContent = t.name;
|
||||
li.addEventListener("click", (e) => {
|
||||
if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
|
||||
openCableTypeModal(t);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
|
||||
renderLegend();
|
||||
});
|
||||
ul.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyHint() {
|
||||
const hint = $("#empty-hint");
|
||||
if (!state.active) {
|
||||
hint.textContent = state.projects.length
|
||||
? "Pick a project from the dropdown to start drawing."
|
||||
: "Create your first project to get started.";
|
||||
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 ---------- //
|
||||
|
||||
async function activateProject(id) {
|
||||
if (id == null) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const snap = await getSnapshot(id);
|
||||
state.active = snap.project;
|
||||
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) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
alert(`Failed to load project: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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.
|
||||
if (state.tool === "frame") {
|
||||
startFrameRubberBand(e, p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "device") {
|
||||
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) => {
|
||||
if (activeNamer === fo) { fo.remove(); activeNamer = null; }
|
||||
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 };
|
||||
|
||||
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) =>
|
||||
btn.addEventListener("click", () => dialog.close()),
|
||||
);
|
||||
}
|
||||
|
||||
function showError(el, msg) {
|
||||
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
|
||||
el.textContent = msg;
|
||||
setHidden(el, false);
|
||||
}
|
||||
|
||||
function openNewProjectModal() {
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
|
||||
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
|
||||
const err = $("#np-error");
|
||||
form.reset();
|
||||
showError(err, "");
|
||||
dlg.showModal();
|
||||
form.elements.namedItem("name").focus();
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const body = {
|
||||
name: String(fd.get("name") || "").trim(),
|
||||
drawing_name: String(fd.get("drawing_name") || "").trim(),
|
||||
description: String(fd.get("description") || ""),
|
||||
};
|
||||
if (!body.drawing_name) delete body.drawing_name;
|
||||
try {
|
||||
const p = await createProject(body);
|
||||
state.projects = await listProjects();
|
||||
dlg.close();
|
||||
await activateProject(p.id);
|
||||
} catch (e) {
|
||||
showError(err, e.message || "Failed to create project");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function openCableTypeModal(existing) {
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
|
||||
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
|
||||
const err = $("#ct-error");
|
||||
const title = $("#ct-title");
|
||||
form.reset();
|
||||
showError(err, "");
|
||||
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
|
||||
|
||||
if (existing) {
|
||||
form.elements.namedItem("name").value = existing.name;
|
||||
form.elements.namedItem("color").value = existing.color;
|
||||
} else {
|
||||
form.elements.namedItem("color").value = "#1971c2";
|
||||
}
|
||||
|
||||
const actions = form.querySelector(".actions");
|
||||
actions.querySelector(".btn-delete-type")?.remove();
|
||||
if (existing) {
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn btn-danger btn-delete-type";
|
||||
del.style.marginRight = "auto";
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", async () => {
|
||||
try {
|
||||
await deleteCableType(existing.id);
|
||||
state.cableTypes = await listCableTypes();
|
||||
if (state.activeTypeId === existing.id) state.activeTypeId = null;
|
||||
dlg.close();
|
||||
render();
|
||||
} catch (e) {
|
||||
const n = e.details?.in_use_by_cables;
|
||||
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
|
||||
}
|
||||
});
|
||||
actions.prepend(del);
|
||||
}
|
||||
|
||||
dlg.showModal();
|
||||
form.elements.namedItem("name").focus();
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const body = {
|
||||
name: String(fd.get("name") || "").trim(),
|
||||
color: String(fd.get("color") || "").trim(),
|
||||
};
|
||||
try {
|
||||
if (existing) await patchCableType(existing.id, body);
|
||||
else await createCableType(body);
|
||||
state.cableTypes = await listCableTypes();
|
||||
dlg.close();
|
||||
render();
|
||||
} catch (e) {
|
||||
showError(err, e.message || "Save failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function openDeleteProjectModal() {
|
||||
if (!state.active) return;
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
|
||||
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
|
||||
const err = $("#dp-error");
|
||||
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
|
||||
form.reset();
|
||||
showError(err, "");
|
||||
input.placeholder = state.active.name;
|
||||
dlg.showModal();
|
||||
input.focus();
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const confirm = String(new FormData(form).get("confirm") || "");
|
||||
try {
|
||||
await deleteProject(state.active.id, confirm);
|
||||
state.projects = await listProjects();
|
||||
dlg.close();
|
||||
await activateProject(null);
|
||||
} catch (e) {
|
||||
showError(err, e.message || "Delete failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- boot ---------- //
|
||||
|
||||
async function boot() {
|
||||
bindCloseButtons($("#modal-new-project"));
|
||||
bindCloseButtons($("#modal-cable-type"));
|
||||
bindCloseButtons($("#modal-delete-project"));
|
||||
|
||||
$("#btn-new-project").addEventListener("click", openNewProjectModal);
|
||||
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
|
||||
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
|
||||
|
||||
$("#project-select").addEventListener("change", (e) => {
|
||||
const v = /** @type {HTMLSelectElement} */ (e.target).value;
|
||||
activateProject(v ? Number(v) : null);
|
||||
});
|
||||
|
||||
bindTools();
|
||||
|
||||
try {
|
||||
[state.projects, state.cableTypes] = await Promise.all([
|
||||
listProjects(),
|
||||
listCableTypes(),
|
||||
]);
|
||||
} catch (e) {
|
||||
alert(`Failed to load: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const wanted = activeProjectIdFromURL();
|
||||
if (wanted && state.projects.some((p) => p.id === wanted)) {
|
||||
await activateProject(wanted);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
370
web/static/style.css
Normal file
370
web/static/style.css
Normal file
@@ -0,0 +1,370 @@
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f4f4f5;
|
||||
--border: #d4d4d8;
|
||||
--text: #18181b;
|
||||
--text-muted: #71717a;
|
||||
--accent: #1971c2;
|
||||
--danger: #e03131;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
--radius: 4px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------- topbar ---------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.project-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.topbar-spacer { flex: 1; }
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 280px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.inspector {
|
||||
background: var(--surface);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar { border-right: 1px solid var(--border); }
|
||||
.inspector { border-left: 1px solid var(--border); }
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.tool-list,
|
||||
.legend-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
.legend-row:hover { background: var(--surface-2); }
|
||||
.legend-row[aria-current="true"] {
|
||||
background: var(--surface-2);
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
.legend-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.legend-name { flex: 1; }
|
||||
.legend-edit {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.legend-edit:hover { color: var(--text); background: var(--surface-2); }
|
||||
|
||||
/* ---------- canvas ---------- */
|
||||
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f7f7f7;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.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 {
|
||||
font: inherit;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
|
||||
|
||||
.btn-tiny { padding: 2px 8px; font-size: 12px; }
|
||||
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn-primary:hover { background: #155da3; }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn-danger:hover { background: #b02828; }
|
||||
|
||||
/* ---------- dialog ---------- */
|
||||
|
||||
.modal {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
|
||||
}
|
||||
.modal::backdrop { background: rgba(0,0,0,0.3); }
|
||||
.modal form { padding: 16px; }
|
||||
.modal h2 { margin: 0 0 12px 0; font-size: 16px; }
|
||||
.modal .banner {
|
||||
background: #fff8e1;
|
||||
border: 1px solid #f5d76e;
|
||||
color: #5b4500;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.modal .actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.modal .form-error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
margin: 6px 0 0 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.field span {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.field input,
|
||||
.field textarea {
|
||||
font: inherit;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
23
web/web.go
Normal file
23
web/web.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary
|
||||
// via embed.FS so deploying mCables means shipping one file.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:static
|
||||
var assets embed.FS
|
||||
|
||||
// Static returns the frontend filesystem rooted at the package's static/
|
||||
// dir so callers see index.html at "/".
|
||||
func Static() fs.FS {
|
||||
sub, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
// embed sub-rooting can only fail if "static" doesn't exist,
|
||||
// which is a build-time error. Panic is the right shape.
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
Reference in New Issue
Block a user