merge: slice 2 — frames + devices + drag-to-position

picasso shipped (3 commits @ b159131):
- internal/db/frames_devices.go: project-scoped CRUD, cross-project FK
  rejection, sentinel errors (duplicate name -> 409, invalid input -> 400)
- internal/server/frames_devices.go: handlers under /api/projects/:pid/
  {frames,devices}, full CRUD
- web/static: SVG rendering + tools (+ Frm rubber-band, + Dev click-place),
  drag with frame-children-follow, inspector with debounced edits

30 store tests green with -race. Hand-test: cross-frame device drag,
frame-drag-with-children, server restart all preserve state.
This commit is contained in:
mAi
2026-05-15 18:23:37 +02:00
9 changed files with 1569 additions and 26 deletions

View 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
}

View 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)
}
}

View File

@@ -19,13 +19,43 @@ type CableType struct {
UpdatedAt string `json:"updated_at"`
}
// Frame is a sub-zone inside a project (`desk`, `rack`, …).
type Frame struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Device is a hardware item inside a project, optionally inside a frame.
type Device struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
Name string `json:"name"`
Color string `json:"color"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Snapshot is the editor's one-shot loader payload for a single project.
// Slice 1 returns the project + the global cable_types; the other arrays
// are present but empty until later slices ship their CRUD.
// Arrays for collections still gated by future slices stay non-nil [] so
// JSON encodes as [] not null.
type Snapshot struct {
Project Project `json:"project"`
Frames []any `json:"frames"`
Devices []any `json:"devices"`
Frames []Frame `json:"frames"`
Devices []Device `json:"devices"`
Ports []any `json:"ports"`
Cables []any `json:"cables"`
IOMarkers []any `json:"io_markers"`

View File

@@ -147,8 +147,9 @@ func (s *Store) DeleteProject(id int64, confirmName string) error {
return nil
}
// Snapshot loads the full editor-init payload for one project. In slice
// 1 the project-scoped collections are still empty.
// Snapshot loads the full editor-init payload for one project. Slice 2
// populates frames + devices; ports / cables / io_markers / bundles
// still ship empty until their slices land.
func (s *Store) Snapshot(id int64) (*Snapshot, error) {
p, err := s.GetProject(id)
if err != nil {
@@ -158,10 +159,18 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
if err != nil {
return nil, err
}
frames, err := s.ListFrames(id)
if err != nil {
return nil, err
}
devices, err := s.ListDevices(id, nil)
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: []any{},
Devices: []any{},
Frames: frames,
Devices: devices,
Ports: []any{},
Cables: []any{},
IOMarkers: []any{},

View 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)
}

View File

@@ -33,6 +33,18 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/cable-types/{id}", h.patchCableType)
mux.HandleFunc("DELETE /api/cable-types/{id}", h.deleteCableType)
// Frames (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/frames", h.listFrames)
mux.HandleFunc("POST /api/projects/{pid}/frames", h.createFrame)
mux.HandleFunc("PATCH /api/projects/{pid}/frames/{id}", h.patchFrame)
mux.HandleFunc("DELETE /api/projects/{pid}/frames/{id}", h.deleteFrame)
// Devices (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/devices", h.listDevices)
mux.HandleFunc("POST /api/projects/{pid}/devices", h.createDevice)
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
mux.Handle("/", http.FileServerFS(frontend))

View File

@@ -35,8 +35,8 @@
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
<ul class="tool-list">
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Frame</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Device</button></li>
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
</ul>
@@ -58,7 +58,9 @@
<aside class="inspector" aria-label="Inspector">
<h2 class="sidebar-heading">Inspector</h2>
<p class="muted">Nothing selected.</p>
<div id="inspector-body">
<p class="muted">Nothing selected.</p>
</div>
</aside>
</main>

View File

@@ -1,23 +1,34 @@
// mCables frontend entry — vanilla ES module, no build step.
//
// Slice 1 covers: list/create/delete projects, list/create/edit/delete
// global cable types, and reflect the active project in ?project=<id>.
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
// inline naming, inspector for selection. State stays minimal: one
// snapshot from the server, then individual PATCHes on each mutation.
/**
* @typedef {{ id: number, name: string, drawing_name: string,
* description: string, created_at: string, updated_at: string }} Project
* @typedef {{ id: number, name: string, color: string,
* created_at: string, updated_at: string }} CableType
* @typedef {{ id: number, project_id: number, name: string,
* x: number, y: number, width: number, height: number }} Frame
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* name: string, color: string,
* x: number, y: number, width: number, height: number }} Device
*/
const API = "/api";
const SVG_NS = "http://www.w3.org/2000/svg";
const state = {
/** @type {Project[]} */ projects: [],
/** @type {CableType[]} */ cableTypes: [],
/** @type {Project | null} */ active: null,
/** active cable-type id (used for drawing in later slices) */
activeTypeId: null,
/** @type {Frame[]} */ frames: [],
/** @type {Device[]} */ devices: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | null */
tool: /** @type {string|null} */ (null),
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -42,7 +53,6 @@ async function api(method, path, body) {
const listProjects = () => api("GET", "/projects");
const createProject = (body) => api("POST", "/projects", body);
const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body);
const deleteProject = (id, confirm) =>
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
const getSnapshot = (id) => api("GET", `/projects/${id}`);
@@ -52,6 +62,14 @@ const createCableType = (body) => api("POST", "/cable-types", body);
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body);
const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body);
const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`);
const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body);
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -61,6 +79,15 @@ function setHidden(el, hidden) {
else el.removeAttribute("hidden");
}
function svgEl(name, attrs = {}) {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
el.setAttribute(k, String(v));
}
return el;
}
// ---------- URL state ---------- //
function activeProjectIdFromURL() {
@@ -76,14 +103,39 @@ function setActiveInURL(id) {
history.replaceState(null, "", url.toString());
}
// ---------- geometry ---------- //
/** Returns the smallest frame whose bbox contains (x, y), or null. */
function frameAt(x, y) {
/** @type {Frame|null} */ let best = null;
let bestArea = Infinity;
for (const f of state.frames) {
if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue;
const a = f.width * f.height;
if (a < bestArea) { best = f; bestArea = a; }
}
return best;
}
/** Convert a pointer event to SVG-canvas coordinates. */
function svgPoint(evt) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
// ---------- render ---------- //
function renderProjectPicker() {
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
const current = state.active?.id ?? "";
sel.innerHTML = "";
const blank = new Option("— pick a project —", "");
sel.append(blank);
sel.append(new Option("— pick a project —", ""));
for (const p of state.projects) {
const opt = new Option(p.name, String(p.id));
if (p.id === current) opt.selected = true;
@@ -126,16 +178,214 @@ function renderEmptyHint() {
? "Pick a project from the dropdown to start drawing."
: "Create your first project to get started.";
setHidden(hint, false);
} else {
hint.textContent = `${state.active.name} — slice 1: empty canvas. Frames + devices arrive in slice 2.`;
setHidden(hint, false);
return;
}
if (state.frames.length === 0 && state.devices.length === 0) {
hint.textContent = `${state.active.name} — empty. Use + Frame / + Device to start (press F or D).`;
setHidden(hint, false);
} else {
setHidden(hint, true);
}
}
function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id });
const rect = svgEl("rect", {
x: f.x, y: f.y, width: f.width, height: f.height,
class: "frame-rect svg-draggable",
rx: 6, ry: 6,
});
if (state.selection?.kind === "frame" && state.selection.id === f.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: f.x + 8, y: f.y + 18,
class: "frame-label",
});
label.textContent = f.name;
g.append(rect, label);
gFrames.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
}
for (const d of state.devices) {
const g = svgEl("g", { "data-device-id": d.id });
const rect = svgEl("rect", {
x: d.x, y: d.y, width: d.width, height: d.height,
class: "device-rect svg-draggable",
stroke: d.color,
rx: 3, ry: 3,
});
if (state.selection?.kind === "device" && state.selection.id === d.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: d.x + d.width / 2, y: d.y + d.height / 2,
class: "device-label",
});
label.textContent = d.name;
g.append(rect, label);
gDevices.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
}
}
function renderInspector() {
const body = $("#inspector-body");
if (!state.selection) {
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
return;
}
if (state.selection.kind === "frame") {
renderInspectorFrame(body, state.selection.id);
} else {
renderInspectorDevice(body, state.selection.id);
}
}
function renderInspectorFrame(body, id) {
const f = state.frames.find((x) => x.id === id);
if (!f) { body.innerHTML = ""; return; }
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
body.innerHTML = `
<p class="section-title">Frame</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="frm-name" value="" />
</label>
<dl>
<dt>x</dt><dd id="frm-x"></dd>
<dt>y</dt><dd id="frm-y"></dd>
<dt>w</dt><dd id="frm-w"></dd>
<dt>h</dt><dd id="frm-h"></dd>
<dt>devices</dt><dd id="frm-count"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
</div>
`;
body.querySelector("#frm-name").value = f.name;
body.querySelector("#frm-x").textContent = f.x.toFixed(0);
body.querySelector("#frm-y").textContent = f.y.toFixed(0);
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
body.querySelector("#frm-count").textContent = String(deviceCount);
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
if (!state.active) return;
const updated = await patchFrame(state.active.id, f.id, { name });
Object.assign(f, updated);
renderCanvas();
});
body.querySelector("#frm-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete frame "${f.name}"? Its devices stay but lose their frame.`)) return;
deleteFrame(state.active.id, f.id).then(() => {
state.frames = state.frames.filter((x) => x.id !== f.id);
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
function renderInspectorDevice(body, id) {
const d = state.devices.find((x) => x.id === id);
if (!d) { body.innerHTML = ""; return; }
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
body.innerHTML = `
<p class="section-title">Device</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="dev-name" value="" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" class="inline-input" id="dev-color" />
</label>
<dl>
<dt>x</dt><dd id="dev-x"></dd>
<dt>y</dt><dd id="dev-y"></dd>
<dt>w</dt><dd id="dev-w"></dd>
<dt>h</dt><dd id="dev-h"></dd>
<dt>frame</dt><dd id="dev-frame"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
</div>
`;
body.querySelector("#dev-name").value = d.name;
body.querySelector("#dev-color").value = d.color;
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
body.querySelector("#dev-h").textContent = d.height.toFixed(0);
body.querySelector("#dev-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#dev-name"), async (name) => {
if (!state.active) return;
const updated = await patchDevice(state.active.id, d.id, { name });
Object.assign(d, updated);
renderCanvas();
});
// Colour changes need no debounce — the native colour picker only fires
// `change` on commit.
body.querySelector("#dev-color").addEventListener("change", async (e) => {
if (!state.active) return;
const color = /** @type {HTMLInputElement} */ (e.target).value;
try {
const updated = await patchDevice(state.active.id, d.id, { color });
Object.assign(d, updated);
renderCanvas();
} catch (err) {
alert(`Colour update failed: ${err.message}`);
}
});
body.querySelector("#dev-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete device "${d.name}"?`)) return;
deleteDevice(state.active.id, d.id).then(() => {
state.devices = state.devices.filter((x) => x.id !== d.id);
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
function bindDebouncedRename(input, persist) {
let timer = null;
input.addEventListener("input", () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const v = input.value.trim();
if (v) persist(v).catch((e) => alert(`Save failed: ${e.message}`));
}, 400);
});
input.addEventListener("blur", () => {
if (timer) { clearTimeout(timer); timer = null; }
const v = input.value.trim();
if (v && v !== input.dataset.last) {
persist(v).catch((e) => alert(`Save failed: ${e.message}`));
input.dataset.last = v;
}
});
}
function render() {
renderProjectPicker();
renderLegend();
renderCanvas();
renderEmptyHint();
renderInspector();
}
// ---------- active project ---------- //
@@ -143,6 +393,9 @@ function render() {
async function activateProject(id) {
if (id == null) {
state.active = null;
state.frames = [];
state.devices = [];
state.selection = null;
setActiveInURL(null);
render();
return;
@@ -150,15 +403,17 @@ async function activateProject(id) {
try {
const snap = await getSnapshot(id);
state.active = snap.project;
// The snapshot also returns the global cable types — refresh from
// the source of truth so a stale state.cableTypes can never linger.
state.frames = snap.frames || [];
state.devices = snap.devices || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
setActiveInURL(id);
render();
} catch (err) {
if (err.status === 404) {
// The id in the URL points to a deleted project — clear it.
state.active = null;
state.frames = [];
state.devices = [];
setActiveInURL(null);
render();
} else {
@@ -167,7 +422,259 @@ async function activateProject(id) {
}
}
// ---------- modals ---------- //
// ---------- tools ---------- //
function armTool(tool) {
if (state.tool === tool) tool = null; // toggle off
state.tool = tool;
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
}
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
}
document.addEventListener("keydown", (e) => {
// Avoid stealing keys while user is typing into an input.
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device");
});
// Canvas-level pointerdown handles tool activation + selection clearing.
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
}
let rubberBand = /** @type {SVGRectElement|null} */ (null);
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
function onCanvasPointerDown(e) {
if (!state.active) return;
// Ignore clicks that started on a device/frame — their own handlers
// captured the pointer already.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id]")) return;
const p = svgPoint(e);
if (state.tool === "frame") {
startFrameRubberBand(e, p);
return;
}
if (state.tool === "device") {
placeDeviceAt(p);
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) =>
@@ -226,7 +733,6 @@ function openCableTypeModal(existing) {
form.elements.namedItem("color").value = "#1971c2";
}
// Slot in a Delete button when editing an existing type.
const actions = form.querySelector(".actions");
actions.querySelector(".btn-delete-type")?.remove();
if (existing) {
@@ -314,6 +820,8 @@ async function boot() {
activateProject(v ? Number(v) : null);
});
bindTools();
try {
[state.projects, state.cableTypes] = await Promise.all([
listProjects(),

View File

@@ -165,6 +165,122 @@ body {
.muted { color: var(--text-muted); }
/* ---------- canvas elements ---------- */
.frame-rect {
fill: rgba(25, 113, 194, 0.04);
stroke: var(--accent);
stroke-width: 1.5;
stroke-dasharray: 6 4;
}
.frame-rect.selected,
.frame-rect:hover { stroke-width: 2.5; }
.frame-label {
fill: var(--accent);
font-size: 13px;
font-weight: 600;
pointer-events: none;
}
.device-rect {
fill: #fff;
stroke: var(--text);
stroke-width: 1.5;
}
.device-rect.selected { stroke-width: 3; }
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
.device-label {
fill: var(--text);
font-size: 12px;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
.svg-draggable { cursor: grab; }
.svg-draggable.dragging { cursor: grabbing; }
/* tool cursor on the empty canvas while a tool is armed */
.canvas-wrap.tool-frame #canvas,
.canvas-wrap.tool-device #canvas { cursor: crosshair; }
.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 {