Compare commits

...

10 Commits

Author SHA1 Message Date
38dba05ac0 Double grid cell thumbnail size to 128px 2026-03-21 00:21:32 +00:00
99299ed9f2 Show grid cell image in component detail inventory entries
Grid-type inventory entries now display the warped cell image from the
grid as a read-only thumbnail (highlighted with accent border) alongside
any user-uploaded images for that entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:20:41 +00:00
b66b2f95d3 Add test data box to template editor for live preview
Enter a JS snippet returning a fields object (e.g. return { resistance: '10k' })
to preview the formatter against synthetic data instead of the first real component.
Both the formatter and test data textareas update the preview on input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:18:26 +00:00
896b6fcb39 Fix template formatters: expose c.fields by name not ID
c.fields was keyed by generated field IDs, so c.fields?.resistance
was always undefined. Now fields are remapped by name before being
passed to formatters, so the documented API works as expected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:15:37 +00:00
64157013ed Fix cell inventory: clickable entries navigate to component; use display name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:14:19 +00:00
3675c1725a Add future-plans.md: app architecture refactor notes 2026-03-21 00:09:27 +00:00
57c697cbfc Add component name formatters and grid-link navigation
Templates section:
- Define JS formatter functions per template (e.g. resistor, capacitor)
- First non-null result from any formatter is used as display name
- Live preview in template editor against first component
- Display names applied in component list, detail view, and inventory rows

Grid navigation:
- Grid-type inventory entries in component detail view show a '⊞' button
  to navigate directly to that grid's viewer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:07:01 +00:00
27970e74f9 Stop tracking package-lock.json (already in .gitignore) 2026-03-20 23:54:42 +00:00
08501539dd Allow creating fields and components inline from dialogs
- Component dialog: 'New...' button next to field selector creates a
  new field definition and immediately adds it to the component form
- Inventory dialog: same pattern for component selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:54:34 +00:00
6c37912ec5 Add grid cell inventory linking and component quick-create
- New 'grid' location type on inventory entries (grid_id, grid_row, grid_col)
- Clicking a grid cell shows a popup with what's stored there
- Popup has '+ Add entry' pre-filled with the cell coordinates
- Inventory dialog: 'New...' button next to component selector opens
  component creation dialog on top, returns with new component selected
- Grid entries display as e.g. 'Black Component Box R3C5' in lists
- Store original filename on source image upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:53:06 +00:00
9 changed files with 756 additions and 850 deletions

42
future-plans.md Normal file
View File

@@ -0,0 +1,42 @@
# Future Plans
## Big refactor: app architecture
### parse_url mutates too many module-level variables
`parse_url()` directly assigns to a large number of module-level state variables
(`section`, `grid_view_state`, `grid_tab`, `current_grid_id`, `grid_draft`,
`current_panel_idx`, `grid_source_id`). This is fragile and hard to reason about.
Preferred direction: represent the full UI state as a single immutable state object,
and have `parse_url()` return a new state value rather than mutating globals.
Something like:
```js
function parse_url(path) {
// returns a state object, touches nothing external
return { section, grid_view_state, current_grid_id, ... };
}
```
Then the caller assigns it: `state = parse_url(location.pathname); render(state);`
### render() if/else chain
The render dispatcher violates stated preferences — long chains of bare `else if`
branches. Replace with a lookup table of arrow functions:
```js
const SECTION_RENDERERS = {
components: render_components,
inventory: render_inventory,
fields: render_fields,
grids: render_grids,
templates: render_templates,
};
function render() {
sync_nav();
SECTION_RENDERERS[section]?.();
}
```
### General module structure
As the app grows, `app.mjs` is becoming a monolith. Consider splitting into
per-section modules (e.g. `views/components.mjs`, `views/grids.mjs`) that each
export their render function and own their local state.

View File

@@ -125,6 +125,28 @@ export function delete_source_image(id) {
return store.delete(`s:${id}`);
}
// --- Component templates ---
export function list_component_templates() {
const result = [];
for (const [key] of store.data.entries()) {
if (key.startsWith('ct:')) result.push(store.get(key));
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
export function get_component_template(id) {
return store.get(`ct:${id}`) ?? null;
}
export function set_component_template(tmpl) {
store.set(`ct:${tmpl.id}`, tmpl);
}
export function delete_component_template(id) {
return store.delete(`ct:${id}`);
}
// --- Grid images ---
export function list_grid_images() {

829
package-lock.json generated
View File

@@ -1,829 +0,0 @@
{
"name": "electronics-inventory",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "electronics-inventory",
"version": "0.1.0",
"dependencies": {
"express": "^5.2.1"
},
"engines": {
"node": ">=25"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

View File

@@ -25,19 +25,21 @@ let grid_source_id = null;
let grid_draft = null; // { id?, name, rows, cols, panel_rows, panel_cols, panels[], edit_grid_id? }
let current_panel_idx = null;
let all_drafts = [];
let all_templates = [];
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function load_all() {
const [cf, ci, cmp, gr, dr, sr] = await Promise.all([
const [cf, ci, cmp, gr, dr, sr, ct] = await Promise.all([
api.get_fields(),
api.get_inventory(),
api.get_components(),
api.get_grids(),
api.get_grid_drafts(),
api.get_source_images(),
api.get_component_templates(),
]);
all_fields = cf.fields;
all_inventory = ci.entries;
@@ -45,20 +47,76 @@ async function load_all() {
all_grids = gr.grids;
all_drafts = dr.drafts;
all_sources = sr.sources;
all_templates = ct.templates;
compile_templates();
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const LOCATION_TYPE_LABEL = { physical: 'Physical', bom: 'BOM', digital: 'Digital' };
const LOCATION_TYPE_LABEL = { physical: 'Physical', bom: 'BOM', digital: 'Digital', grid: 'Grid' };
function ref_label_for_type(type) {
if (type === 'physical') return 'Location (drawer, bin, shelf…)';
if (type === 'bom') return 'Document / project name';
if (type === 'grid') return 'Grid cell';
return 'Note / description';
}
function grid_cell_label(entry) {
const grid = all_grids.find(g => g.id === entry.grid_id);
if (!grid) { return `Grid cell (R${(entry.grid_row ?? 0) + 1}C${(entry.grid_col ?? 0) + 1})`; }
return `${grid.name} R${(entry.grid_row ?? 0) + 1}C${(entry.grid_col ?? 0) + 1}`;
}
// ---------------------------------------------------------------------------
// Component template engine
// ---------------------------------------------------------------------------
let compiled_formatters = []; // [{ id, name, fn }]
function compile_templates() {
compiled_formatters = [];
for (const tmpl of all_templates) {
if (!tmpl.formatter?.trim()) continue;
try {
// eslint-disable-next-line no-new-func
const fn = new Function('c', `"use strict"; return (${tmpl.formatter})(c);`);
compiled_formatters.push({ id: tmpl.id, name: tmpl.name, fn });
} catch (err) {
console.warn(`Template "${tmpl.name}" failed to compile:`, err);
}
}
}
// Build a version of the component where c.fields is keyed by field name
// instead of field ID, so formatters can use c.fields?.resistance etc.
function named_fields_comp(comp) {
const fields = {};
for (const [fid, val] of Object.entries(comp.fields ?? {})) {
const def = field_by_id(fid);
if (def) { fields[def.name] = val; }
}
return { ...comp, fields };
}
function component_display_name(comp) {
if (!compiled_formatters.length) return comp.name;
const c = named_fields_comp(comp);
for (const { fn } of compiled_formatters) {
try {
const result = fn(c);
if (result != null && result !== '') return String(result);
} catch (_) {
// formatter threw — skip it
}
}
return comp.name;
}
// ---------------------------------------------------------------------------
function inventory_for_component(component_id) {
return all_inventory.filter(e => e.component_id === component_id);
}
@@ -177,7 +235,7 @@ function render_component_list() {
function build_component_row(comp) {
const row = clone('t-component-row');
set_text(row, '.component-name', comp.name);
set_text(row, '.component-name', component_display_name(comp));
const tags_el = qs(row, '.component-tags');
const field_entries = Object.entries(comp.fields ?? {});
@@ -222,11 +280,11 @@ function render_detail_panel() {
const content = clone('t-detail-content');
// Header
set_text(content, '.detail-name', comp.name);
set_text(content, '.detail-name', component_display_name(comp));
set_text(content, '.detail-description', comp.description || '');
qs(content, '.detail-edit-btn').addEventListener('click', () => open_component_dialog(comp));
qs(content, '.detail-delete-btn').addEventListener('click', () => confirm_delete(
`Delete component "${comp.name}"? Inventory entries will become orphaned.`,
`Delete component "${component_display_name(comp)}"? Inventory entries will become orphaned.`,
async () => {
await api.delete_component(comp.id);
all_components = all_components.filter(c => c.id !== comp.id);
@@ -297,10 +355,33 @@ function build_detail_inv_entry(entry) {
type_el.className = `type-pill type-${entry.location_type}`;
type_el.textContent = LOCATION_TYPE_LABEL[entry.location_type] ?? entry.location_type;
set_text(el, '.detail-inv-ref', entry.location_ref || '—');
set_text(el, '.detail-inv-ref', entry.location_type === 'grid' ? grid_cell_label(entry) : (entry.location_ref || '—'));
set_text(el, '.detail-inv-qty', entry.quantity ? `×${entry.quantity}` : '');
set_text(el, '.detail-inv-notes', entry.notes || '');
if (entry.location_type === 'grid' && entry.grid_id) {
const goto_btn = qs(el, '.detail-inv-goto-grid');
goto_btn.hidden = false;
goto_btn.addEventListener('click', () => navigate(`/grids/viewer/${entry.grid_id}`));
// Show the grid cell image as a read-only thumbnail
const grid = all_grids.find(g => g.id === entry.grid_id);
const cell_filename = grid?.cells?.[entry.grid_row]?.[entry.grid_col];
if (cell_filename) {
const thumb = document.createElement('a');
thumb.className = 'thumb-link cell-thumb-link';
thumb.href = `/img/${cell_filename}`;
thumb.target = '_blank';
thumb.rel = 'noopener';
const img = document.createElement('img');
img.className = 'thumb-img';
img.src = `/img/${cell_filename}`;
img.alt = 'Grid cell';
thumb.appendChild(img);
qs(el, '.inv-image-grid').before(thumb);
}
}
// Inventory entry images
build_image_grid(
qs(el, '.inv-image-grid'),
@@ -382,20 +463,20 @@ function render_inventory_list() {
function build_inventory_row(entry) {
const row = clone('t-inventory-row');
const comp = component_by_id(entry.component_id);
set_text(row, '.inv-component-name', comp ? comp.name : '(deleted component)');
set_text(row, '.inv-component-name', comp ? component_display_name(comp) : '(deleted component)');
const pill = document.createElement('span');
pill.className = `type-pill type-${entry.location_type}`;
pill.textContent = LOCATION_TYPE_LABEL[entry.location_type] ?? entry.location_type;
qs(row, '.inv-type-badge').replaceChildren(pill);
set_text(row, '.inv-location-ref', entry.location_ref);
set_text(row, '.inv-location-ref', entry.location_type === 'grid' ? grid_cell_label(entry) : entry.location_ref);
set_text(row, '.inv-quantity', entry.quantity);
set_text(row, '.inv-notes', entry.notes);
qs(row, '.btn-edit').addEventListener('click', () => open_inventory_dialog(entry));
qs(row, '.btn-delete').addEventListener('click', () => confirm_delete(
`Delete this inventory entry (${LOCATION_TYPE_LABEL[entry.location_type]}: ${entry.location_ref || '—'})?`,
`Delete this inventory entry (${LOCATION_TYPE_LABEL[entry.location_type]}: ${entry.location_type === 'grid' ? grid_cell_label(entry) : (entry.location_ref || '—')})?`,
async () => {
await api.delete_inventory(entry.id);
all_inventory = all_inventory.filter(e => e.id !== entry.id);
@@ -455,6 +536,135 @@ function build_field_row(fdef) {
return row;
}
// ---------------------------------------------------------------------------
// Render: Templates section
// ---------------------------------------------------------------------------
let template_dialog = null;
let template_dialog_callback = null;
function render_templates() {
const main = document.getElementById('main');
let section_el = document.getElementById('section-templates');
if (!section_el) {
const frag = document.getElementById('t-section-templates').content.cloneNode(true);
main.replaceChildren(frag);
section_el = document.getElementById('section-templates');
qs(section_el, '#btn-add-template').addEventListener('click', () => open_template_dialog());
}
render_template_list();
}
function render_template_list() {
const list_el = document.getElementById('template-list');
if (!list_el) return;
if (all_templates.length === 0) {
const note = document.createElement('p');
note.className = 'section-empty-note';
note.textContent = 'No templates yet. Add one to automatically format component display names.';
list_el.replaceChildren(note);
return;
}
list_el.replaceChildren(...all_templates.map(tmpl => {
const card = clone('t-template-card');
set_text(card, '.template-card-name', tmpl.name);
qs(card, '.template-card-formatter').textContent = tmpl.formatter || '(empty)';
qs(card, '.btn-edit').addEventListener('click', () => open_template_dialog(tmpl));
qs(card, '.btn-delete').addEventListener('click', () => confirm_delete(
`Delete template "${tmpl.name}"?`,
async () => {
await api.delete_component_template(tmpl.id);
all_templates = all_templates.filter(t => t.id !== tmpl.id);
compile_templates();
render_template_list();
}
));
return card;
}));
}
function open_template_dialog(tmpl = null) {
if (!template_dialog) {
const frag = document.getElementById('t-dialog-template').content.cloneNode(true);
document.body.appendChild(frag);
template_dialog = document.getElementById('dialog-template');
qs(template_dialog, '#tmpl-cancel').addEventListener('click', () => template_dialog.close());
document.getElementById('form-template').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await template_dialog_callback?.();
template_dialog.close();
compile_templates();
render_template_list();
render(); // refresh component names everywhere
} catch (err) { alert(`Error: ${err.message}`); }
});
// Live preview
qs(template_dialog, '#tmpl-formatter').addEventListener('input', update_tmpl_preview);
qs(template_dialog, '#tmpl-test-data').addEventListener('input', update_tmpl_preview);
}
qs(template_dialog, '.dialog-title').textContent = tmpl ? 'Edit template' : 'Add template';
qs(template_dialog, '#tmpl-name').value = tmpl?.name ?? '';
qs(template_dialog, '#tmpl-formatter').value = tmpl?.formatter ?? '';
qs(template_dialog, '#tmpl-test-data').value = '';
update_tmpl_preview();
template_dialog_callback = async () => {
const body = {
name: qs(template_dialog, '#tmpl-name').value.trim(),
formatter: qs(template_dialog, '#tmpl-formatter').value.trim(),
};
if (tmpl) {
const result = await api.update_component_template(tmpl.id, body);
const idx = all_templates.findIndex(t => t.id === tmpl.id);
if (idx !== -1) all_templates[idx] = result.template;
} else {
const result = await api.create_component_template(body);
all_templates.push(result.template);
}
};
template_dialog.showModal();
qs(template_dialog, '#tmpl-name').focus();
}
function update_tmpl_preview() {
if (!template_dialog) return;
const preview_el = qs(template_dialog, '#tmpl-preview');
const formatter_str = qs(template_dialog, '#tmpl-formatter').value.trim();
if (!formatter_str) { preview_el.textContent = '—'; return; }
// Build the component to preview against
let preview_comp;
const test_data_str = qs(template_dialog, '#tmpl-test-data').value.trim();
if (test_data_str) {
try {
// eslint-disable-next-line no-new-func
const test_fields = new Function(test_data_str)();
preview_comp = { name: '(test)', fields: test_fields ?? {} };
} catch (err) {
preview_el.textContent = `Test data error: ${err.message}`;
return;
}
} else {
const sample = all_components[0];
if (!sample) { preview_el.textContent = '(no components to preview)'; return; }
preview_comp = named_fields_comp(sample);
}
try {
// eslint-disable-next-line no-new-func
const fn = new Function('c', `"use strict"; return (${formatter_str})(c);`);
const result = fn(preview_comp);
preview_el.textContent = result != null ? String(result) : `null — falls back to "${preview_comp.name}"`;
} catch (err) {
preview_el.textContent = `Formatter error: ${err.message}`;
}
}
// ---------------------------------------------------------------------------
// Render: Grids section
// ---------------------------------------------------------------------------
@@ -991,16 +1201,82 @@ function render_grid_viewer() {
if (filename) {
const img = qs(cell, '.grid-cell-img');
img.src = `/img/${filename}`;
img.addEventListener('click', () => window.open(`/img/${filename}`, '_blank'));
} else {
cell.classList.add('empty');
}
set_text(cell, '.grid-cell-label', `${row},${col}`);
set_text(cell, '.grid-cell-label', `R${row + 1}C${col + 1}`);
cell.addEventListener('click', (e) => open_cell_inventory(grid, row, col, e));
return cell;
});
cells_el.replaceChildren(...all_cells);
}
function open_cell_inventory(grid, row, col, e) {
// Remove any existing overlay
document.getElementById('cell-inventory-overlay')?.remove();
const frag = document.getElementById('t-cell-inventory').content.cloneNode(true);
document.body.appendChild(frag);
const overlay = document.getElementById('cell-inventory-overlay');
// Position near click
const x = Math.min(e.clientX + 8, window.innerWidth - 340);
const y = Math.min(e.clientY + 8, window.innerHeight - 200);
overlay.style.left = x + 'px';
overlay.style.top = y + 'px';
qs(overlay, '.cell-inventory-title').textContent = `${grid.name} R${row + 1}C${col + 1}`;
const entries = all_inventory.filter(inv =>
inv.location_type === 'grid' && inv.grid_id === grid.id &&
inv.grid_row === row && inv.grid_col === col
);
const list_el = qs(overlay, '.cell-inventory-list');
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'cell-inv-empty';
empty.textContent = 'Nothing stored here yet';
list_el.appendChild(empty);
} else {
entries.forEach(entry => {
const comp = component_by_id(entry.component_id);
const item = document.createElement('div');
item.className = 'cell-inv-item cell-inv-item-link';
const name_span = document.createElement('span');
name_span.textContent = comp ? component_display_name(comp) : '?';
const qty_span = document.createElement('span');
qty_span.className = 'cell-inv-qty';
qty_span.textContent = entry.quantity || '';
item.append(name_span, qty_span);
if (comp) {
item.addEventListener('click', () => {
overlay.remove();
selected_component_id = comp.id;
navigate('/components');
});
}
list_el.appendChild(item);
});
}
qs(overlay, '#cell-inv-close').addEventListener('click', () => overlay.remove());
qs(overlay, '#cell-inv-add').addEventListener('click', () => {
overlay.remove();
open_inventory_dialog(null, null, { grid_id: grid.id, grid_row: row, grid_col: col });
});
// Close on outside click
setTimeout(() => {
document.addEventListener('click', function handler(ev) {
if (!overlay.contains(ev.target)) {
overlay.remove();
document.removeEventListener('click', handler);
}
});
}, 0);
}
// ---------------------------------------------------------------------------
// Dialog: Component
// ---------------------------------------------------------------------------
@@ -1091,6 +1367,23 @@ function open_component_dialog(comp = null) {
};
add_field_sel.addEventListener('change', add_field_sel._change_handler);
const new_field_btn = qs(dlg, '#c-new-field');
new_field_btn.onclick = () => {
const known_ids = new Set(all_fields.map(f => f.id));
open_field_dialog(null);
document.getElementById('dialog-field').addEventListener('close', () => {
const new_field = all_fields.find(f => !known_ids.has(f.id));
rebuild_add_select();
if (new_field) {
active_fields.set(new_field.id, '');
rebuild_field_rows();
rebuild_add_select();
const inputs = field_rows_el.querySelectorAll(`[data-field_id="${new_field.id}"]`);
if (inputs.length) inputs[inputs.length - 1].focus();
}
}, { once: true });
};
component_dialog_callback = async () => {
const name = name_input.value.trim();
if (!name) return;
@@ -1121,7 +1414,7 @@ function open_component_dialog(comp = null) {
let inventory_dialog_callback = null;
function open_inventory_dialog(entry = null, default_component_id = null) {
function open_inventory_dialog(entry = null, default_component_id = null, default_grid_cell = null) {
const dlg = document.getElementById('dialog-inventory');
const title = qs(dlg, '.dialog-title');
const comp_sel = qs(dlg, '#i-component');
@@ -1130,6 +1423,11 @@ function open_inventory_dialog(entry = null, default_component_id = null) {
const ref_label = qs(dlg, '#i-ref-label');
const qty_input = qs(dlg, '#i-qty');
const notes_input = qs(dlg, '#i-notes');
const new_comp_btn = qs(dlg, '#i-new-component');
const grid_row_div = qs(dlg, '#i-grid-row');
const grid_sel = qs(dlg, '#i-grid-select');
const row_num_input = qs(dlg, '#i-grid-row-num');
const col_num_input = qs(dlg, '#i-grid-col-num');
title.textContent = entry ? 'Edit inventory entry' : 'Add inventory entry';
@@ -1141,13 +1439,35 @@ function open_inventory_dialog(entry = null, default_component_id = null) {
}))
);
comp_sel.value = entry?.component_id ?? default_component_id ?? '';
type_sel.value = entry?.location_type ?? 'physical';
const effective_grid_cell = default_grid_cell ?? (entry?.location_type === 'grid' ? { grid_id: entry.grid_id, grid_row: entry.grid_row, grid_col: entry.grid_col } : null);
if (effective_grid_cell) {
type_sel.value = 'grid';
} else {
type_sel.value = entry?.location_type ?? 'physical';
}
ref_input.value = entry?.location_ref ?? '';
qty_input.value = entry?.quantity ?? '';
notes_input.value = entry?.notes ?? '';
// Populate grid selector
grid_sel.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— select grid —' }),
...all_grids.map(g => Object.assign(document.createElement('option'), {
value: g.id,
textContent: g.name,
}))
);
if (effective_grid_cell?.grid_id) { grid_sel.value = effective_grid_cell.grid_id; }
row_num_input.value = effective_grid_cell?.grid_row != null ? effective_grid_cell.grid_row + 1 : 1;
col_num_input.value = effective_grid_cell?.grid_col != null ? effective_grid_cell.grid_col + 1 : 1;
function update_ref_label() {
ref_label.textContent = ref_label_for_type(type_sel.value);
const is_grid = type_sel.value === 'grid';
grid_row_div.hidden = !is_grid;
ref_input.closest('.form-row').hidden = is_grid;
}
update_ref_label();
@@ -1156,13 +1476,33 @@ function open_inventory_dialog(entry = null, default_component_id = null) {
type_sel._change_handler = update_ref_label;
type_sel.addEventListener('change', type_sel._change_handler);
new_comp_btn.onclick = () => {
const known_ids = new Set(all_components.map(c => c.id));
open_component_dialog(null);
document.getElementById('dialog-component').addEventListener('close', () => {
// Rebuild the selector and select the newly created component (if any)
comp_sel.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— select component —' }),
...all_components.map(c => Object.assign(document.createElement('option'), {
value: c.id, textContent: c.name,
}))
);
const new_comp = all_components.find(c => !known_ids.has(c.id));
if (new_comp) comp_sel.value = new_comp.id;
}, { once: true });
};
inventory_dialog_callback = async () => {
const is_grid = type_sel.value === 'grid';
const body = {
component_id: comp_sel.value,
location_type: type_sel.value,
location_ref: ref_input.value.trim(),
location_ref: is_grid ? '' : ref_input.value.trim(),
quantity: qty_input.value.trim(),
notes: notes_input.value.trim(),
grid_id: is_grid ? (grid_sel.value || null) : null,
grid_row: is_grid ? parseInt(row_num_input.value) - 1 : null,
grid_col: is_grid ? parseInt(col_num_input.value) - 1 : null,
};
if (entry) {
const result = await api.update_inventory(entry.id, body);
@@ -1255,6 +1595,8 @@ function parse_url() {
section = 'inventory';
} else if (p0 === 'fields') {
section = 'fields';
} else if (p0 === 'templates') {
section = 'templates';
} else if (p0 === 'grids') {
section = 'grids';
if (p1 === 'sources') {
@@ -1334,6 +1676,7 @@ function render() {
else if (section === 'inventory') render_inventory();
else if (section === 'fields') render_fields();
else if (section === 'grids') render_grids();
else if (section === 'templates') render_templates();
}
// ---------------------------------------------------------------------------

View File

@@ -15,6 +15,7 @@
<button class="nav-btn" data-section="inventory">Inventory</button>
<button class="nav-btn" data-section="fields">Fields</button>
<button class="nav-btn" data-section="grids">Grids</button>
<button class="nav-btn" data-section="templates">Templates</button>
</nav>
</header>
<main id="main"></main>

View File

@@ -38,6 +38,12 @@ export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`
export const get_source_images = () => req('GET', '/api/source-images');
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
// Component templates
export const get_component_templates = () => req('GET', '/api/component-templates');
export const create_component_template = (body) => req('POST', '/api/component-templates', body);
export const update_component_template = (id, body) => req('PUT', `/api/component-templates/${id}`, body);
export const delete_component_template = (id) => req('DELETE', `/api/component-templates/${id}`);
// Grid images
export const get_grids = () => req('GET', '/api/grid-images');
export const get_grid = (id) => req('GET', `/api/grid-images/${id}`);

View File

@@ -370,6 +370,21 @@ nav {
gap: 0.4rem;
}
.cell-thumb-link {
display: block;
border: 2px solid var(--accent, #5b9cf6);
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
}
.cell-thumb-link .thumb-img {
display: block;
width: 128px;
height: 128px;
object-fit: cover;
}
/* ===== SECTION TOOLBAR ===== */
.section-toolbar {
@@ -795,6 +810,71 @@ nav {
line-height: 1.5;
}
/* ===== TEMPLATES SECTION ===== */
.template-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.template-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
}
.template-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.template-card-name {
font-weight: 600;
}
.template-card-formatter {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-dim);
white-space: pre-wrap;
margin: 0;
}
.code-input {
font-family: var(--font-mono);
font-size: 0.85rem;
width: 100%;
resize: vertical;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
}
.tmpl-preview-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
min-height: 1.5rem;
}
.tmpl-preview-value {
font-family: var(--font-mono);
color: var(--accent, #5b9cf6);
}
.section-empty-note {
color: var(--text-dim);
font-size: 0.9rem;
margin: 1rem 0;
}
/* ===== TAB BAR ===== */
.tab-bar {
@@ -1019,6 +1099,17 @@ nav {
/* ===== FORM ROW PAIR ===== */
.input-with-action {
display: flex;
gap: 0.4rem;
align-items: center;
}
.input-with-action .filter-select {
flex: 1;
min-width: 0;
}
.form-row-pair {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1311,3 +1402,106 @@ nav {
text-align: center;
font-family: var(--font-mono);
}
/* ===== GRID TYPE PILL ===== */
.type-pill.type-grid {
background: #2e7d4f;
color: #fff;
}
/* ===== GRID CELL PICKER (inventory dialog) ===== */
.i-grid-cell-picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.i-grid-coords {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.input-narrow {
width: 4rem;
}
/* ===== CELL INVENTORY OVERLAY ===== */
.grid-cell {
cursor: pointer;
}
.cell-inventory-overlay {
position: fixed;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
min-width: 220px;
max-width: 320px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
z-index: 100;
}
.cell-inventory-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.cell-inventory-title {
font-weight: 600;
font-size: 0.9rem;
}
.cell-inventory-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
min-height: 1rem;
}
.cell-inv-item {
font-size: 0.85rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.cell-inv-qty {
color: var(--text-dim);
white-space: nowrap;
}
.cell-inv-item-link {
cursor: pointer;
border-radius: 3px;
padding: 0.1rem 0.25rem;
margin: 0 -0.25rem;
}
.cell-inv-item-link:hover {
background: var(--hover, rgba(255,255,255,0.06));
}
.cell-inv-empty {
font-size: 0.85rem;
color: var(--text-faint);
font-style: italic;
}
.cell-inventory-actions {
display: flex;
justify-content: flex-end;
}
.btn-sm {
padding: 0.25rem 0.6rem;
font-size: 0.82rem;
}

View File

@@ -82,6 +82,7 @@
<span class="detail-inv-qty"></span>
<span class="detail-inv-notes"></span>
<span class="row-actions">
<button class="btn-icon btn-goto-grid detail-inv-goto-grid" title="View in grid" hidden></button>
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
@@ -115,6 +116,7 @@
<option value="physical">Physical</option>
<option value="bom">BOM / Drawing</option>
<option value="digital">Digital / Note</option>
<option value="grid">Grid cell</option>
</select>
<button class="btn btn-primary" id="btn-add-inventory">+ Add entry</button>
</div>
@@ -146,6 +148,58 @@
</tr>
</template>
<!-- ===== TEMPLATES SECTION ===== -->
<template id="t-section-templates">
<section class="section" id="section-templates">
<div class="section-toolbar">
<span class="section-note">Formatters that compute display names from component fields</span>
<button class="btn btn-primary" id="btn-add-template">+ Add template</button>
</div>
<div id="template-list" class="template-list"></div>
</section>
</template>
<template id="t-template-card">
<div class="template-card">
<div class="template-card-header">
<span class="template-card-name"></span>
<span class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</div>
<pre class="template-card-formatter"></pre>
</div>
</template>
<template id="t-dialog-template">
<dialog id="dialog-template" class="app-dialog app-dialog-wide">
<h2 class="dialog-title"></h2>
<form method="dialog" id="form-template">
<div class="form-row">
<label for="tmpl-name">Name</label>
<input type="text" id="tmpl-name" required autocomplete="off" placeholder="e.g. Resistor">
</div>
<div class="form-row">
<label for="tmpl-formatter">Formatter <span class="label-hint">(JS arrow function, return null to skip)</span></label>
<textarea id="tmpl-formatter" rows="8" class="code-input" placeholder="(c) => {&#10; const r = c.fields?.resistance;&#10; if (!r) return null;&#10; return `Resistor ${r}`;&#10;}"></textarea>
</div>
<div class="form-row">
<label for="tmpl-test-data">Test data <span class="label-hint">(optional — return a fields object to preview against)</span></label>
<textarea id="tmpl-test-data" rows="3" class="code-input" placeholder="return { resistance: '10k', mounting_tech: 'PTH' }"></textarea>
</div>
<div class="tmpl-preview-row">
<span class="label-hint">Preview:</span>
<span id="tmpl-preview" class="tmpl-preview-value"></span>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="tmpl-cancel">Cancel</button>
<button type="submit" class="btn btn-primary" id="tmpl-save">Save</button>
</div>
</form>
</dialog>
</template>
<!-- ===== FIELDS SECTION ===== -->
<template id="t-section-fields">
<section class="section" id="section-fields">
@@ -335,9 +389,12 @@
<div class="form-section-label">Field values</div>
<div id="c-field-rows"></div>
<div class="form-row add-field-row">
<select id="c-add-field-select" class="filter-select">
<option value="">— add a field —</option>
</select>
<div class="input-with-action">
<select id="c-add-field-select" class="filter-select">
<option value="">— add a field —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="c-new-field">New…</button>
</div>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="c-cancel">Cancel</button>
@@ -354,9 +411,12 @@
<form method="dialog" id="form-inventory">
<div class="form-row">
<label for="i-component">Component</label>
<select id="i-component" required class="filter-select wide">
<option value="">— select component —</option>
</select>
<div class="input-with-action">
<select id="i-component" required class="filter-select">
<option value="">— select component —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="i-new-component">New…</button>
</div>
</div>
<div class="form-row">
<label for="i-type">Location type</label>
@@ -364,12 +424,25 @@
<option value="physical">Physical location</option>
<option value="bom">BOM / Drawing</option>
<option value="digital">Digital / Note</option>
<option value="grid">Grid cell</option>
</select>
</div>
<div class="form-row">
<label for="i-ref" id="i-ref-label">Location reference</label>
<input type="text" id="i-ref" autocomplete="off">
</div>
<div class="form-row" id="i-grid-row" hidden>
<label>Grid cell</label>
<div class="i-grid-cell-picker">
<select id="i-grid-select" class="filter-select"></select>
<div class="i-grid-coords">
<label for="i-grid-row-num">Row</label>
<input type="number" id="i-grid-row-num" min="1" class="input-narrow">
<label for="i-grid-col-num">Col</label>
<input type="number" id="i-grid-col-num" min="1" class="input-narrow">
</div>
</div>
</div>
<div class="form-row">
<label for="i-qty">Quantity</label>
<input type="text" id="i-qty" autocomplete="off" placeholder="e.g. 10, ~50, see BOM">
@@ -479,3 +552,17 @@
</div>
</dialog>
</template>
<!-- ===== CELL INVENTORY OVERLAY ===== -->
<template id="t-cell-inventory">
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
<div class="cell-inventory-header">
<span class="cell-inventory-title"></span>
<button type="button" class="btn-icon" id="cell-inv-close"></button>
</div>
<div class="cell-inventory-list" id="cell-inventory-list"></div>
<div class="cell-inventory-actions">
<button type="button" class="btn btn-primary btn-sm" id="cell-inv-add">+ Add entry</button>
</div>
</div>
</template>

View File

@@ -15,6 +15,7 @@ import {
list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft,
list_source_images, get_source_image, add_source_image, delete_source_image,
list_grid_images, get_grid_image, set_grid_image, delete_grid_image,
list_component_templates, get_component_template, set_component_template, delete_component_template,
} from './lib/storage.mjs';
mkdirSync('./data/images', { recursive: true });
@@ -156,7 +157,8 @@ app.get('/api/inventory', (req, res) => {
});
app.post('/api/inventory', (req, res) => {
const { component_id, location_type, location_ref = '', quantity = '', notes = '' } = req.body;
const { component_id, location_type, location_ref = '', quantity = '', notes = '',
grid_id = null, grid_row = null, grid_col = null } = req.body;
if (!component_id) return fail(res, 'component_id is required');
if (!location_type) return fail(res, 'location_type is required');
if (!get_component(component_id)) return fail(res, 'component not found', 404);
@@ -168,6 +170,9 @@ app.post('/api/inventory', (req, res) => {
location_ref: String(location_ref).trim(),
quantity: String(quantity).trim(),
notes: String(notes).trim(),
grid_id: grid_id ?? null,
grid_row: grid_row != null ? parseInt(grid_row) : null,
grid_col: grid_col != null ? parseInt(grid_col) : null,
images: [],
created_at: now,
updated_at: now,
@@ -185,6 +190,9 @@ app.put('/api/inventory/:id', (req, res) => {
if (location_ref !== undefined) updated.location_ref = String(location_ref).trim();
if (quantity !== undefined) updated.quantity = String(quantity).trim();
if (notes !== undefined) updated.notes = String(notes).trim();
if (req.body.grid_id !== undefined) updated.grid_id = req.body.grid_id ?? null;
if (req.body.grid_row !== undefined) updated.grid_row = req.body.grid_row != null ? parseInt(req.body.grid_row) : null;
if (req.body.grid_col !== undefined) updated.grid_col = req.body.grid_col != null ? parseInt(req.body.grid_col) : null;
set_inventory_entry(updated);
ok(res, { entry: updated });
});
@@ -416,6 +424,38 @@ app.delete('/api/grid-images/:id', (req, res) => {
ok(res);
});
// ---------------------------------------------------------------------------
// Component templates
// ---------------------------------------------------------------------------
app.get('/api/component-templates', (req, res) => {
ok(res, { templates: list_component_templates() });
});
app.post('/api/component-templates', (req, res) => {
const { name, formatter } = req.body;
if (!name) return fail(res, 'name is required');
const tmpl = { id: generate_id(), name, formatter: formatter ?? '', created_at: Date.now(), updated_at: Date.now() };
set_component_template(tmpl);
ok(res, { template: tmpl });
});
app.put('/api/component-templates/:id', (req, res) => {
const existing = get_component_template(req.params.id);
if (!existing) return fail(res, 'not found', 404);
const updated = { ...existing, updated_at: Date.now() };
if (req.body.name !== undefined) updated.name = req.body.name;
if (req.body.formatter !== undefined) updated.formatter = req.body.formatter;
set_component_template(updated);
ok(res, { template: updated });
});
app.delete('/api/component-templates/:id', (req, res) => {
if (!get_component_template(req.params.id)) return fail(res, 'not found', 404);
delete_component_template(req.params.id);
ok(res);
});
// SPA fallback — serve index.html for any non-API, non-asset path
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));