@@ -56,14 +56,11 @@ const state = {
/** @type {Bundle[]} */ bundles : [ ] ,
/** @type {SetupTemplate[]} */ setupTemplates : [ ] ,
activeTypeId : /** @type {number|null} */ ( null ) ,
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
/** "frame" | "device" | "io" | "req" | "cable" | null */
tool : /** @type {string|null} */ ( null ) ,
/** Slice-7 transient state for the +Port tool. */
portToolDevice : /** @type {number|null} */ ( null ) ,
portToolTypeID : /** @type {number|null} */ ( null ) ,
/** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID : /** @type {number|null} */ ( null ) ,
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | null} */ selection : null ,
/** @type {( {kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection : null ,
} ;
// ---------- API client ---------- //
@@ -88,6 +85,7 @@ async function api(method, path, body) {
const listProjects = ( ) => api ( "GET" , "/projects" ) ;
const createProject = ( body ) => api ( "POST" , "/projects" , body ) ;
const patchProject = ( id , body ) => api ( "PATCH" , ` /projects/ ${ id } ` , body ) ;
const deleteProject = ( id , confirm ) =>
api ( "DELETE" , ` /projects/ ${ id } ?confirm= ${ encodeURIComponent ( confirm ) } ` ) ;
const getSnapshot = ( id ) => api ( "GET" , ` /projects/ ${ id } ` ) ;
@@ -116,6 +114,9 @@ const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
const createCableAPI = ( pid , body ) => api ( "POST" , ` /projects/ ${ pid } /cables ` , body ) ;
const listDeviceTypesForProject = ( pid ) => api ( "GET" , ` /projects/ ${ pid } /device-types ` ) ;
const createDeviceType = ( pid , body ) => api ( "POST" , ` /projects/ ${ pid } /device-types ` , body ) ;
const patchDeviceType = ( pid , id , body ) => api ( "PATCH" , ` /projects/ ${ pid } /device-types/ ${ id } ` , body ) ;
const deleteDeviceType = ( pid , id ) => api ( "DELETE" , ` /projects/ ${ pid } /device-types/ ${ id } ` ) ;
const createRequirement = ( pid , body ) => api ( "POST" , ` /projects/ ${ pid } /connection-requirements ` , body ) ;
const patchRequirement = ( pid , id , body ) => api ( "PATCH" , ` /projects/ ${ pid } /connection-requirements/ ${ id } ` , body ) ;
@@ -441,6 +442,7 @@ function renderInspector() {
case "requirement" : return renderInspectorRequirement ( body , state . selection . id ) ;
case "cable" : return renderInspectorCable ( body , state . selection . id ) ;
case "port" : return renderInspectorPort ( body , state . selection . id ) ;
case "port_new" : return renderInspectorPortNew ( body , state . selection . device _id ) ;
default : body . innerHTML = ` <p class="muted">Nothing selected.</p> ` ;
}
}
@@ -727,13 +729,24 @@ function renderInspectorDevice(body, id) {
} ) ;
} ) ;
// +Port — arms the port-placement gesture. Active cable type comes
// from the legend selection; if none, defaults to the first cable_type .
// +Port — switch the inspector to the new-port form. m fills in
// type + edge + label and clicks Create; no canvas click required .
body . querySelector ( "#dev-add-port" ) . addEventListener ( "click" , ( ) => {
if ( ! state . active ) return ;
const typeID = state . activeTypeId ? ? state . cableTypes [ 0 ] ? . id ;
if ( ! typeID ) { al ert ( "Pick a cable type in the legend first" ) ; return ; }
armPortTool ( d . id , typeID ) ;
state . selection = { kind : "port_new" , device _id : d . id } ;
rend er( ) ;
} ) ;
// Clicking a port row in the device's port list selects that port
// and opens its editor in the inspector pane.
body . querySelectorAll ( ".port-row[data-port-id]" ) . forEach ( ( row ) => {
row . addEventListener ( "click" , ( e ) => {
if ( e . target instanceof HTMLElement && e . target . closest ( ".port-del" ) ) return ;
const pid = Number ( row . getAttribute ( "data-port-id" ) ) ;
if ( ! pid ) return ;
state . selection = { kind : "port" , id : pid } ;
render ( ) ;
} ) ;
} ) ;
// Per-port delete.
@@ -1003,27 +1016,26 @@ function renderInspectorIO(body, id) {
} ) ;
}
// Slice 7 follow-up: m can select a port to edit its edge / label / delete.
// Port editor — type / edge / label / delete. m can also navigate back
// to the device by clicking "back to device" or anywhere on the device.
function renderInspectorPort ( body , id ) {
const prt = state . ports . find ( ( p ) => p . id === id ) ;
if ( ! prt ) { body . innerHTML = "" ; return ; }
const dev = state . devices . find ( ( d ) => d . id === prt . device _id ) ;
if ( ! dev ) { body . innerHTML = "" ; return ; }
const ct = state . cableTypes . find ( ( t ) => t . id === prt . type _id ) ;
const ctColor = ct ? . color || "#888" ;
const ctName = ct ? . name || "?" ;
const currentEdge = portEdge ( prt , dev ) ;
const typeOptions = state . cableTypes
. map ( ( t ) => ` <option value=" ${ t . id } "> ${ escapeHtml ( t . name ) } </option> ` )
. join ( "" ) ;
body . innerHTML = `
<p class="section-title">Port</p>
<dl >
<dt>device</dt><dd> ${ dev . name } </dd >
<dt>type</dt >
<dd><span class="swatch" style="background: ${ ctColor } "></span> ${ ctName } </dd>
</dl>
<p style="font-size:12px;margin:0 0 8px 0;" >
<a href="#" id="port-back-device" class="btn-link">← ${ escapeHtml ( dev . name ) } </a >
</p >
<label class="field">
<span>Label </span>
<input class="inline-input" id="port-label" value="" / >
<span>Type </span>
<select id="port-type"> ${ typeOptions } </select >
</label>
<label class="field">
<span>Edge</span>
@@ -1034,12 +1046,36 @@ function renderInspectorPort(body, id) {
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
</div>
` ;
body . querySelector ( "#port-label " ) . value = prt . label ? ? "" ;
body . querySelector ( "#port-type " ) . value = String ( prt . type _id ) ;
body . querySelector ( "#port-edge" ) . value = currentEdge ;
body . querySelector ( "#port-label" ) . value = prt . label ? ? "" ;
body . querySelector ( "#port-back-device" ) . addEventListener ( "click" , ( e ) => {
e . preventDefault ( ) ;
state . selection = { kind : "device" , id : dev . id } ;
render ( ) ;
} ) ;
body . querySelector ( "#port-type" ) . addEventListener ( "change" , async ( e ) => {
if ( ! state . active ) return ;
const newTypeID = Number ( /** @type {HTMLSelectElement} */ ( e . target ) . value ) ;
if ( newTypeID === prt . type _id ) return ;
try {
const updated = await patchPort ( state . active . id , prt . id , { type _id : newTypeID } ) ;
Object . assign ( prt , updated ) ;
renderCanvas ( ) ;
} catch ( ex ) {
alert ( ` Type change failed: ${ ex . message } ` ) ;
}
} ) ;
bindDebouncedRename ( body . querySelector ( "#port-label" ) , async ( label ) => {
if ( ! state . active ) return ;
@@ -1110,6 +1146,114 @@ function edgeCentre(dev, edge) {
}
}
// Compute the next available default label for a new port of `typeID`
// on `deviceID`. e.g. if a TV already has "HDMI 1" and "HDMI 2", a new
// HDMI port gets "HDMI 3".
function nextDefaultPortLabel ( deviceID , typeID ) {
const ct = state . cableTypes . find ( ( t ) => t . id === typeID ) ;
const prefix = ct ? . name || "Port" ;
const sibs = state . ports . filter ( ( p ) => p . device _id === deviceID && p . type _id === typeID ) ;
let max = 0 ;
for ( const p of sibs ) {
const m = ( p . label || "" ) . match ( new RegExp ( "^" + escapeRegExp ( prefix ) + "\\s+(\\d+)$" ) ) ;
if ( m ) {
const n = parseInt ( m [ 1 ] , 10 ) ;
if ( n > max ) max = n ;
}
}
return ` ${ prefix } ${ Math . max ( max + 1 , sibs . length + 1 ) } ` ;
}
function escapeRegExp ( s ) {
return s . replace ( /[.*+?^${}()|[\]\\]/g , "\\$&" ) ;
}
// "Add port" form. Submit → POST → switch inspector to the new port's
// editor. m can cancel back to the device inspector.
function renderInspectorPortNew ( body , deviceID ) {
const dev = state . devices . find ( ( d ) => d . id === deviceID ) ;
if ( ! dev ) { body . innerHTML = "" ; return ; }
if ( state . cableTypes . length === 0 ) {
body . innerHTML = `
<p class="section-title">Add port</p>
<p class="muted">No cable types defined. Add one from the legend first.</p>
<div class="inspector-actions">
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div> ` ;
body . querySelector ( "#port-new-cancel" ) . addEventListener ( "click" , ( ) => {
state . selection = { kind : "device" , id : dev . id } ;
render ( ) ;
} ) ;
return ;
}
const defaultTypeID = state . activeTypeId ? ? state . cableTypes [ 0 ] . id ;
const typeOptions = state . cableTypes
. map ( ( t ) => ` <option value=" ${ t . id } "> ${ escapeHtml ( t . name ) } </option> ` )
. join ( "" ) ;
body . innerHTML = `
<p class="section-title">Add port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-new-back" class="btn-link">← ${ escapeHtml ( dev . name ) } </a>
</p>
<label class="field">
<span>Type</span>
<select id="port-new-type"> ${ typeOptions } </select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-new-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom" selected>Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-new-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-primary btn-tiny" id="port-new-create">Create</button>
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>
` ;
const typeSel = /** @type {HTMLSelectElement} */ ( body . querySelector ( "#port-new-type" ) ) ;
const edgeSel = /** @type {HTMLSelectElement} */ ( body . querySelector ( "#port-new-edge" ) ) ;
const labelInp = /** @type {HTMLInputElement} */ ( body . querySelector ( "#port-new-label" ) ) ;
typeSel . value = String ( defaultTypeID ) ;
labelInp . value = nextDefaultPortLabel ( dev . id , defaultTypeID ) ;
labelInp . placeholder = labelInp . value ;
// Recompute default label whenever the type changes (only if m hasn't
// edited the field).
let labelUserEdited = false ;
labelInp . addEventListener ( "input" , ( ) => { labelUserEdited = true ; } ) ;
typeSel . addEventListener ( "change" , ( ) => {
if ( labelUserEdited ) return ;
const tid = Number ( typeSel . value ) ;
const next = nextDefaultPortLabel ( dev . id , tid ) ;
labelInp . value = next ;
labelInp . placeholder = next ;
} ) ;
body . querySelector ( "#port-new-back" ) . addEventListener ( "click" , ( e ) => {
e . preventDefault ( ) ;
state . selection = { kind : "device" , id : dev . id } ;
render ( ) ;
} ) ;
body . querySelector ( "#port-new-cancel" ) . addEventListener ( "click" , ( ) => {
state . selection = { kind : "device" , id : dev . id } ;
render ( ) ;
} ) ;
body . querySelector ( "#port-new-create" ) . addEventListener ( "click" , async ( ) => {
const tid = Number ( typeSel . value ) ;
const edge = edgeSel . value ;
const label = labelInp . value . trim ( ) ;
await createPortFromForm ( dev . id , tid , edge , label ) ;
} ) ;
}
function renderInspectorCableType ( body , id ) {
const t = state . cableTypes . find ( ( x ) => x . id === id ) ;
if ( ! t ) { body . innerHTML = "" ; return ; }
@@ -1313,27 +1457,15 @@ function armTool(tool) {
const wrap = $ ( ".canvas-wrap" ) ;
wrap . classList . toggle ( "tool-frame" , tool === "frame" ) ;
wrap . classList . toggle ( "tool-device" , tool === "device" ) ;
wrap . classList . toggle ( "tool-port" , tool === "port" ) ;
wrap . classList . toggle ( "tool-cable" , tool === "cable" ) ;
for ( const btn of document . querySelectorAll ( "[data-tool]" ) ) {
btn . classList . toggle ( "armed" , btn . getAttribute ( "data-tool" ) === tool ) ;
}
if ( tool !== "port" ) {
state . portToolDevice = null ;
state . portToolTypeID = null ;
}
if ( tool !== "cable" ) {
state . cableDrawFromPortID = null ;
}
}
/** Slice 7: device inspector arms +Port for a specific device + type. */
function armPortTool ( deviceID , typeID ) {
state . portToolDevice = deviceID ;
state . portToolTypeID = typeID ;
armTool ( "port" ) ;
}
function bindTools ( ) {
for ( const btn of document . querySelectorAll ( "[data-tool]" ) ) {
btn . addEventListener ( "click" , ( ) => armTool ( btn . getAttribute ( "data-tool" ) ) ) ;
@@ -1385,11 +1517,6 @@ function onCanvasPointerDown(e) {
placeDeviceAt ( p ) ;
return ;
}
if ( state . tool === "port" ) {
e . preventDefault ( ) ;
placePortAt ( p ) ;
return ;
}
if ( state . tool === "io" ) {
e . preventDefault ( ) ;
placeIOMarkerAt ( p ) ;
@@ -1568,27 +1695,8 @@ function openNewDeviceModal(geom) {
} ;
}
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
* y_off) relative to the device's top-left + a debug-friendly edge name. */
function snapToDeviceEdge ( device , x , y ) {
// Distance from the point to each of the four edges.
const dxLeft = Math . abs ( x - device . x ) ;
const dxRight = Math . abs ( ( device . x + device . width ) - x ) ;
const dyTop = Math . abs ( y - device . y ) ;
const dyBottom = Math . abs ( ( device . y + device . height ) - y ) ;
const min = Math . min ( dxLeft , dxRight , dyTop , dyBottom ) ;
// Clamp the perpendicular coordinate so the port sits *on* the rect.
const localX = Math . max ( 0 , Math . min ( device . width , x - device . x ) ) ;
const localY = Math . max ( 0 , Math . min ( device . height , y - device . y ) ) ;
if ( min === dxLeft ) return { xOff : 0 , yOff : localY , edge : "left" } ;
if ( min === dxRight ) return { xOff : device . width , yOff : localY , edge : "right" } ;
if ( min === dyTop ) return { xOff : localX , yOff : 0 , edge : "top" } ;
return { xOff : localX , yOff : device . height , edge : "bottom" } ;
}
// Which edge does a given port currently sit on? Snaps the port's
// existing (x_offset, y_offset) to the nearest of the four edges using
// the same distance heuristic as snapToDeviceEdge.
// existing (x_offset, y_offset) to the nearest of the four edges.
function portEdge ( port , device ) {
const dL = port . x _offset ;
const dR = device . width - port . x _offset ;
@@ -1762,33 +1870,28 @@ async function finishCableDrawAtIO(ioMarker) {
}
}
async function placePortAt ( p ) {
// Create a port from the sidebar "Add port" form and switch the
// inspector to its editor. Used by renderInspectorPortNew on submit.
async function createPortFromForm ( deviceID , typeID , edge , label ) {
if ( ! state . active ) return ;
const did = state . portToolDevice ;
const tid = state . portToolTypeID ;
if ( did == null || tid == null ) { armTool ( null ) ; return ; }
const dev = state . devices . find ( ( d ) => d . id === did ) ;
if ( ! dev ) { armTool ( null ) ; return ; }
const snap = snapToDeviceEdge ( dev , p . x , p . y ) ;
const dev = state . devices . find ( ( d ) => d . id === deviceID ) ;
if ( ! dev ) return ;
const tmp = edgeCentre ( dev , edge ) ;
try {
const port = await createPort ( state . active . id , did , {
type _id : tid ,
x _offset : snap . xOff ,
y _offset : sna p. y Off,
const port = await createPort ( state . active . id , deviceID , {
type _id : typeID ,
label : label || undefined ,
x _offset : tm p. x Off,
y _offset : tmp . yOff ,
} ) ;
state . ports . push ( port ) ;
// Re-layout all ports on this edge so the new one + existing ones
// are evenly spaced — m's invariant: never let two ports stack .
await relayoutEdge ( did , snap . edge ) ;
// Select the freshly-placed port so the inspector switches to the
// port panel (edge dropdown / label / delete) and the .selected halo
// marks it.
// Re-space every port on this edge so the new one slots into the
// even- spacing grid .
await relayoutEdge ( deviceID , edge ) ;
state . selection = { kind : "port" , id : port . id } ;
armTool ( null ) ;
render ( ) ;
} catch ( e ) {
alert ( ` Add port failed: ${ e . message } ` ) ;
armTool ( null ) ;
}
}
@@ -2364,6 +2467,332 @@ async function exportCurrentProject() {
}
}
// ---------- admin modal ---------- //
const adminState = {
activeTab : /** @type {"projects"|"cable-types"|"device-types"|"setup-templates"} */ ( "projects" ) ,
} ;
async function openAdminModal ( ) {
const dlg = /** @type {HTMLDialogElement} */ ( $ ( "#modal-admin" ) ) ;
// Always re-fetch the lists when opening so the modal reflects the
// latest server state (m may have edited things from inspector panes
// while the modal was closed).
try {
state . projects = await listProjects ( ) ;
state . cableTypes = await listCableTypes ( ) ;
state . setupTemplates = await listSetupTemplates ( ) ;
} catch ( e ) {
alert ( ` Failed to load admin data: ${ e . message } ` ) ;
return ;
}
for ( const btn of dlg . querySelectorAll ( ".admin-tab" ) ) {
btn . addEventListener ( "click" , ( ) => switchAdminTab ( btn . getAttribute ( "data-admin-tab" ) ) ) ;
}
switchAdminTab ( adminState . activeTab ) ;
dlg . showModal ( ) ;
}
function switchAdminTab ( name ) {
adminState . activeTab = name ;
for ( const btn of $ ( "#modal-admin" ) . querySelectorAll ( ".admin-tab" ) ) {
const on = btn . getAttribute ( "data-admin-tab" ) === name ;
btn . setAttribute ( "aria-selected" , on ? "true" : "false" ) ;
}
const body = $ ( "#admin-body" ) ;
switch ( name ) {
case "projects" : return renderAdminProjects ( body ) ;
case "cable-types" : return renderAdminCableTypes ( body ) ;
case "device-types" : return renderAdminDeviceTypes ( body ) ;
case "setup-templates" : return renderAdminSetupTemplates ( body ) ;
}
}
// ---------- admin: projects ---------- //
function renderAdminProjects ( body ) {
const rows = state . projects . map ( ( p ) => `
<div class="admin-row" data-project-id=" ${ p . id } ">
<div class="admin-row-title">
<span> ${ escapeHtml ( p . name ) } </span>
<span style="color: var(--text-muted); font-size: 11px;"># ${ p . id } </span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value=" ${ escapeHtml ( p . name ) } " />
</label>
<label class="field"><span>Drawing name</span>
<input class="adm-drawing" type="text" value=" ${ escapeHtml ( p . drawing _name ) } " />
</label>
<label class="field"><span>Description</span>
<textarea class="adm-desc" rows="2"> ${ escapeHtml ( p . description ? ? "" ) } </textarea>
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete…</button>
</div>
</div>
` ) . join ( "" ) || ` <p class="admin-empty">No projects.</p> ` ;
body . innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 12px 0;">
Rename, retitle the drawing, or change the description. Delete cascades all frames /
devices / cables / etc. in the project (cable types are global and unaffected).
</p>
${ rows }
` ;
for ( const row of body . querySelectorAll ( ".admin-row[data-project-id]" ) ) {
const pid = Number ( row . getAttribute ( "data-project-id" ) ) ;
row . querySelector ( ".adm-save" ) . addEventListener ( "click" , async ( ) => {
const name = row . querySelector ( ".adm-name" ) . value . trim ( ) ;
const drawing = row . querySelector ( ".adm-drawing" ) . value . trim ( ) ;
const desc = row . querySelector ( ".adm-desc" ) . value ;
try {
const updated = await patchProject ( pid , {
name , drawing _name : drawing , description : desc ,
} ) ;
const idx = state . projects . findIndex ( ( p ) => p . id === pid ) ;
if ( idx >= 0 ) state . projects [ idx ] = updated ;
if ( state . active ? . id === pid ) state . active = updated ;
renderProjectPicker ( ) ;
switchAdminTab ( "projects" ) ;
} catch ( e ) {
alert ( ` Save failed: ${ e . message } ` ) ;
}
} ) ;
row . querySelector ( ".adm-delete" ) . addEventListener ( "click" , async ( ) => {
const p = state . projects . find ( ( x ) => x . id === pid ) ;
if ( ! p ) return ;
const typed = prompt ( ` Type " ${ p . name } " to confirm delete: ` ) ;
if ( typed !== p . name ) return ;
try {
await deleteProject ( pid , p . name ) ;
state . projects = state . projects . filter ( ( x ) => x . id !== pid ) ;
if ( state . active ? . id === pid ) await activateProject ( null ) ;
switchAdminTab ( "projects" ) ;
} catch ( e ) {
alert ( ` Delete failed: ${ e . message } ` ) ;
}
} ) ;
}
}
// ---------- admin: cable types ---------- //
function renderAdminCableTypes ( body ) {
const rows = state . cableTypes . map ( ( t ) => `
<div class="admin-row" data-cable-type-id=" ${ t . id } ">
<div class="admin-row-title">
<span><span class="swatch" style="background: ${ t . color } "></span> ${ escapeHtml ( t . name ) } </span>
<span style="color: var(--text-muted); font-size: 11px;"># ${ t . id } </span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value=" ${ escapeHtml ( t . name ) } " />
</label>
<label class="field"><span>Colour</span>
<input class="adm-color" type="color" value=" ${ t . color } " />
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
` ) . join ( "" ) || ` <p class="admin-empty">No cable types.</p> ` ;
body . innerHTML = `
<p class="banner" style="margin:0 0 12px 0;">
Cable types are <strong>global</strong> — renaming or recolouring affects every project.
</p>
${ rows }
<div class="admin-add-row">
<div class="admin-row-title"><span>New cable type</span></div>
<label class="field"><span>Name</span>
<input id="adm-ct-new-name" type="text" placeholder="e.g. SATA" />
</label>
<label class="field"><span>Colour</span>
<input id="adm-ct-new-color" type="color" value="#1971c2" />
</label>
<div class="actions">
<button type="button" class="btn btn-primary btn-tiny" id="adm-ct-new-create">+ Add</button>
</div>
</div>
` ;
for ( const row of body . querySelectorAll ( ".admin-row[data-cable-type-id]" ) ) {
const id = Number ( row . getAttribute ( "data-cable-type-id" ) ) ;
row . querySelector ( ".adm-save" ) . addEventListener ( "click" , async ( ) => {
const name = row . querySelector ( ".adm-name" ) . value . trim ( ) ;
const color = row . querySelector ( ".adm-color" ) . value ;
try {
const updated = await patchCableType ( id , { name , color } ) ;
const idx = state . cableTypes . findIndex ( ( t ) => t . id === id ) ;
if ( idx >= 0 ) state . cableTypes [ idx ] = updated ;
renderLegend ( ) ; renderCanvas ( ) ;
switchAdminTab ( "cable-types" ) ;
} catch ( e ) { alert ( ` Save failed: ${ e . message } ` ) ; }
} ) ;
row . querySelector ( ".adm-delete" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( "Delete this cable type? Requires no ports / cables to reference it." ) ) return ;
try {
await deleteCableType ( id ) ;
state . cableTypes = state . cableTypes . filter ( ( t ) => t . id !== id ) ;
renderLegend ( ) ; renderCanvas ( ) ;
switchAdminTab ( "cable-types" ) ;
} catch ( e ) { alert ( ` Delete failed: ${ e . message } ` ) ; }
} ) ;
}
body . querySelector ( "#adm-ct-new-create" ) . addEventListener ( "click" , async ( ) => {
const name = body . querySelector ( "#adm-ct-new-name" ) . value . trim ( ) ;
const color = body . querySelector ( "#adm-ct-new-color" ) . value ;
if ( ! name ) { alert ( "Name required" ) ; return ; }
try {
const created = await createCableType ( { name , color } ) ;
state . cableTypes . push ( created ) ;
renderLegend ( ) ; renderCanvas ( ) ;
switchAdminTab ( "cable-types" ) ;
} catch ( e ) { alert ( ` Create failed: ${ e . message } ` ) ; }
} ) ;
}
// ---------- admin: device types ---------- //
function renderAdminDeviceTypes ( body ) {
if ( ! state . active ) {
body . innerHTML = `
<p class="admin-empty">
Pick a project to manage its custom device types. Built-ins are
listed once a project is active (they're project-agnostic but the
catalog read takes a project context).
</p> ` ;
return ;
}
const cableTypeName = new Map ( state . cableTypes . map ( ( t ) => [ t . id , t . name ] ) ) ;
const cableTypeColor = new Map ( state . cableTypes . map ( ( t ) => [ t . id , t . color ] ) ) ;
const portsLine = ( ports ) => ports . map ( ( p ) =>
` <li><span class="swatch" style="background: ${ cableTypeColor . get ( p . cable _type _id ) || "#888" } "></span> ` +
` ${ escapeHtml ( cableTypeName . get ( p . cable _type _id ) || "?" ) } × ${ p . count } <span class="muted">( ${ escapeHtml ( p . edge ) } )</span></li> ` ,
) . join ( "" ) ;
const builtIns = state . deviceTypes . filter ( ( t ) => t . built _in ) ;
const customs = state . deviceTypes . filter ( ( t ) => ! t . built _in ) ;
const builtRows = builtIns . map ( ( t ) => `
<div class="admin-row locked" data-device-type-id=" ${ t . id } ">
<div class="admin-row-title">
<span> ${ t . icon ? escapeHtml ( t . icon ) + " " : "" } ${ escapeHtml ( t . name ) }
<span class="muted" style="font-weight:normal;font-size:11px;">· ${ escapeHtml ( t . kind || "" ) } </span>
</span>
<span class="locked-badge">built-in</span>
</div>
<p class="muted" style="font-size:12px;margin:0;"> ${ escapeHtml ( t . description || "" ) } </p>
<ul class="port-profile-list"> ${ portsLine ( t . ports || [ ] ) } </ul>
</div>
` ) . join ( "" ) ;
const customRows = customs . map ( ( t ) => `
<div class="admin-row" data-device-type-id=" ${ t . id } ">
<div class="admin-row-title">
<span> ${ t . icon ? escapeHtml ( t . icon ) + " " : "" } ${ escapeHtml ( t . name ) } </span>
<span style="color: var(--text-muted); font-size: 11px;"># ${ t . id } </span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value=" ${ escapeHtml ( t . name ) } " />
</label>
<label class="field"><span>Kind</span>
<input class="adm-kind" type="text" value=" ${ escapeHtml ( t . kind || "" ) } " />
</label>
<label class="field"><span>Icon</span>
<input class="adm-icon" type="text" value=" ${ escapeHtml ( t . icon || "" ) } " />
</label>
<label class="field"><span>Description</span>
<input class="adm-desc" type="text" value=" ${ escapeHtml ( t . description || "" ) } " />
</label>
<ul class="port-profile-list"> ${ portsLine ( t . ports || [ ] ) || '<li class="muted">no port profile</li>' } </ul>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
` ) . join ( "" ) || ` <p class="admin-empty">No project-custom types yet.</p> ` ;
body . innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
Built-in types are seeded by migrations and read-only.
Project-custom types live under the active project (' ${ escapeHtml ( state . active . name ) } ') and can be edited or deleted.
Port profiles can't be re-shaped here yet — m can still override per device-instance from the device inspector.
</p>
<h3 style="margin:8px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Built-in ( ${ builtIns . length } )</h3>
${ builtRows }
<h3 style="margin:16px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Project-custom ( ${ customs . length } )</h3>
${ customRows }
` ;
for ( const row of body . querySelectorAll ( ".admin-row:not(.locked)[data-device-type-id]" ) ) {
const id = Number ( row . getAttribute ( "data-device-type-id" ) ) ;
row . querySelector ( ".adm-save" ) . addEventListener ( "click" , async ( ) => {
const name = row . querySelector ( ".adm-name" ) . value . trim ( ) ;
const kind = row . querySelector ( ".adm-kind" ) . value . trim ( ) ;
const icon = row . querySelector ( ".adm-icon" ) . value . trim ( ) ;
const desc = row . querySelector ( ".adm-desc" ) . value ;
try {
const updated = await patchDeviceType ( state . active . id , id , {
name , kind , icon , description : desc ,
} ) ;
const idx = state . deviceTypes . findIndex ( ( t ) => t . id === id ) ;
if ( idx >= 0 ) state . deviceTypes [ idx ] = updated ;
switchAdminTab ( "device-types" ) ;
} catch ( e ) { alert ( ` Save failed: ${ e . message } ` ) ; }
} ) ;
row . querySelector ( ".adm-delete" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( "Delete this custom device type?" ) ) return ;
try {
await deleteDeviceType ( state . active . id , id ) ;
state . deviceTypes = state . deviceTypes . filter ( ( t ) => t . id !== id ) ;
switchAdminTab ( "device-types" ) ;
} catch ( e ) { alert ( ` Delete failed: ${ e . message } ` ) ; }
} ) ;
}
}
// ---------- admin: setup templates ---------- //
function renderAdminSetupTemplates ( body ) {
const cableTypeName = new Map ( state . cableTypes . map ( ( t ) => [ t . id , t . name ] ) ) ;
const rows = state . setupTemplates . map ( ( t ) => {
const dt = ( d ) => d . device _type ? . name ? ? ` type # ${ d . device _type _id } ` ;
const devsById = new Map ( t . devices . map ( ( d ) => [ d . id , d ] ) ) ;
const devsHtml = t . devices . map ( ( d ) =>
` <li> ${ escapeHtml ( d . suggested _name ? ? dt ( d ) ) } <span class="muted">( ${ escapeHtml ( dt ( d ) ) } )</span></li> ` ,
) . join ( "" ) || ` <li class="muted">no devices</li> ` ;
const reqsHtml = t . requirements . map ( ( r ) => {
const a = devsById . get ( r . from _template _device _id ) ;
const b = devsById . get ( r . to _template _device _id ) ;
const an = a ? ( a . suggested _name ? ? dt ( a ) ) : "?" ;
const bn = b ? ( b . suggested _name ? ? dt ( b ) ) : "?" ;
const ct = r . preferred _cable _type _id != null
? cableTypeName . get ( r . preferred _cable _type _id ) : null ;
const tag = r . must _connect ? "must" : "nice" ;
return ` <li> ${ escapeHtml ( an ) } ↔ ${ escapeHtml ( bn ) } <span class="muted">· ${ escapeHtml ( ct ? ? "solver picks" ) } · ${ tag } </span></li> ` ;
} ) . join ( "" ) || ` <li class="muted">no requirements</li> ` ;
return `
<div class="admin-row locked" data-template-id=" ${ t . id } ">
<div class="admin-row-title">
<span> ${ escapeHtml ( t . name ) } </span>
<span class="locked-badge"> ${ t . built _in ? "built-in" : "custom" } </span>
</div>
<p class="muted" style="font-size:12px;margin:0;"> ${ escapeHtml ( t . description || "" ) } </p>
<div class="tmpl-detail">
<strong>Devices ( ${ t . devices . length } )</strong>
<ul> ${ devsHtml } </ul>
</div>
<div class="tmpl-detail">
<strong>Requirements ( ${ t . requirements . length } )</strong>
<ul> ${ reqsHtml } </ul>
</div>
</div>
` ;
} ) . join ( "" ) || ` <p class="admin-empty">No setup templates.</p> ` ;
body . innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
Setup templates are stamps for a project — apply one from the header
("Apply template…") to seed a frame + devices + requirements at once.
Built-in templates are read-only.
</p>
${ rows }
` ;
}
// ---------- boot ---------- //
async function boot ( ) {
@@ -2374,10 +2803,12 @@ async function boot() {
bindCloseButtons ( $ ( "#modal-requirement" ) ) ;
bindCloseButtons ( $ ( "#modal-solve" ) ) ;
bindCloseButtons ( $ ( "#modal-template" ) ) ;
bindCloseButtons ( $ ( "#modal-admin" ) ) ;
$ ( "#btn-new-project" ) . addEventListener ( "click" , openNewProjectModal ) ;
$ ( "#btn-add-type" ) . addEventListener ( "click" , ( ) => openCableTypeModal ( null ) ) ;
$ ( "#btn-delete-project" ) . addEventListener ( "click" , openDeleteProjectModal ) ;
$ ( "#btn-admin" ) . addEventListener ( "click" , openAdminModal ) ;
$ ( "#btn-add-requirement" ) . addEventListener ( "click" , ( ) => {
if ( ! state . active ) { alert ( "Pick a project first" ) ; return ; }
if ( state . devices . length < 2 ) { alert ( "Need at least two devices to add a requirement." ) ; return ; }