Compare commits

..

27 Commits

Author SHA1 Message Date
270806539c Add ui-structure.md — full UI inventory and widget taxonomy
Covers all 7 nav sections with layout descriptions, all 11 dialog types,
recurring widget patterns (split pane, tabbed view, table, gallery, card,
field editor, modal dialog, non-modal overlay, full-page sub-view, canvas,
lightbox) with every instance listed, complete template inventory (41 entries),
and a primitive taxonomy for a future higher-level UI representation.
Linked from CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:18:09 +00:00
5fe7273e35 Update future-plans: dual event bus, UI sub-project boundary, fix duplicate
- Replace simple SSE note with full dual-bus architecture:
  server EventEmitter + client pub/sub, SSE as bridge, effects/ pattern,
  mutation wrapper to avoid wildcard, collection-level event granularity
- Add 'UI as self-contained sub-project' section: composition root pattern,
  main.mjs vs mock-main.mjs entry points, mock-api contract, views-never-
  call-each-other discipline
- Expand app.mjs monolith note to mention mount() export pattern
- Remove duplicate CodeMirror paragraph (copy-paste artifact)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:12:28 +00:00
72897c5b2d Add CLAUDE.md — agent orientation file
Covers: file map, KV prefix table, all data model shapes, full API
route index, frontend section layout, image file lifecycle, code style
preferences, git conventions, and a pointer to future-plans.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:18:12 +00:00
e2d0079ba0 Make bin editor open to image view; corners editing on demand
- Default view: processed image (full size in dialog), or placeholder if
  not yet processed. Name and type selector always visible at top.
- "Adjust corners…" button reveals the canvas editor (lazy-loaded on
  first click, so there's no canvas allocation cost on open).
- "← Back to image" returns to the image view.
- Corners are only re-processed on Save if the canvas was opened.
- Tabs reduced to Fields | Contents (Corners is no longer a tab).
- Added preview image CSS; btn-link style for the back button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:06:30 +00:00
46ce10289e Wire up fields and contents UI for bins and bin types
- build_field_editor() helper: reusable field row editor shared by
  bin editor, bin type dialog (open_component_dialog still uses its own)
- open_bin_editor: tabs (Corners|Fields|Contents), field editor on
  Fields tab, content list on Contents tab, save always persists fields
- open_bin_type_dialog: field editor appended below existing form fields
- render_bin_contents / open_bin_content_dialog: content item CRUD
  (component ref or free-text name, quantity, notes); add/edit/delete
  update all_bins immediately without closing the editor
- bc-cancel / bc-save handlers registered in init()
- bin-content-row CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:37:32 +00:00
33c8ff274e Add tabs, fields section, and contents section to bin editor dialog
- t-dialog-bin-editor: tabs (Corners|Fields|Contents), field rows, contents list
- t-bin-content-row: row template for bin content items
- t-dialog-bin-content: dialog for adding/editing component or free-text items
- t-dialog-bin-type: append field rows section below existing form fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:34:26 +00:00
2b7d50a53d Add fields and contents to bins; fields to bin types
- bins: fields:{} and contents:[] on all new records
- bin types: fields:{} on all new records
- PUT /api/bins/:id accepts fields
- PUT /api/bin-types/:id accepts fields
- POST/PUT/DELETE /api/bins/:id/contents for content items
  (type: 'component'|'item', component_id or name, quantity, notes)
- api.mjs: add_bin_content, update_bin_content, delete_bin_content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:33:53 +00:00
0319ff7099 Move canvas pan to middle mouse button; left button handles only
Left click now exclusively drags corner/edge handles.
Middle click pans the view. preventDefault on mousedown
suppresses the browser's autoscroll activation on middle click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:27:12 +00:00
5e2a348b9d Expand duplicate plan to cover all entity types; clarify bins need fields too
- Generalized 'Duplicate bin type' to 'Duplicate any entity' covering
  components, bins, bin types, grids, templates, inventory, source images
- Clarified that bins (not just bin types) should carry generic fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:25:22 +00:00
b0eaf4dc10 Constrain edge midpoint drag to edge normal direction
Instead of applying raw 2D delta, project cursor movement onto the
outward normal of each edge so the edge can only be pushed/pulled
perpendicular to itself. Prevents shearing when dragging a side handle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:23:53 +00:00
046fe99c72 Fix canvas coordinate mismatch and handle jump-on-grab in Grid_Setup
- Canvas width now read via getBoundingClientRect after setting style.width=100%,
  avoiding the parentElement.clientWidth padding issue that made css_w exceed
  the actual rendered width and broke hit-testing
- All handle drags (corners + midpoints) now use relative delta via drag_prev_img
  instead of absolute cursor position, preventing handle teleport on grab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:20:09 +00:00
ede87bb90f Notes: generic fields on bins/bin types, field groups as domain filter, duplicate bin type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:15:38 +00:00
7670db2c6e Note: SSE-based live updates when data changes from any client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:12:51 +00:00
1aa7350c4d Add maintenance: purge orphaned source image KV entries
When a source image file is deleted without going through the API
(e.g. the old bin delete bug), the KV entry remains and shows a
broken image. The new maintenance action scans all source image
entries, removes any whose file is missing on disk, and reports
how many were cleaned up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:08:38 +00:00
b200a7ec8d Fix bin delete removing source image
Source images are shared entities managed through the Images gallery.
Deleting a bin should only remove the processed output (image_filename),
not the source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:07:29 +00:00
7e70864907 Note: replace flat prefixed KV keys with hierarchical collection structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:06:33 +00:00
090f6f3154 Add bin types: reusable named dimension presets for bins
Bin types store a name, physical W×H in mm, and optional description.
When editing a bin, a type can be selected from a dropdown; this
pre-fills and locks the dimension inputs. Custom dimensions remain
available when no type is selected.

- lib/storage.mjs: bin type CRUD with bt: prefix
- server.mjs: /api/bin-types CRUD routes; type_id accepted on bin
  create/update routes; DELETE protected if any bin references the type;
  type dims copied onto bin when type_id is set
- public/lib/api.mjs: bin type wrappers; rename_bin → update_bin (accepts
  any fields)
- public/templates.html: Types tab in bins section; t-bin-type-row;
  t-dialog-bin-type; type selector in bin editor dialog
- public/app.mjs: all_bin_types state loaded at startup; render_bin_types_list();
  open_bin_type_dialog(); type selector in open_bin_editor(); /bins/types routing
- public/style.css: bin types list styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:04:04 +00:00
320c6f1bd9 Add physical dimensions to bin editor for correct aspect ratio
When de-perspectiving a bin photo taken at an angle, inferring the
output size from the quadrilateral shape squishes the result. Entering
real-world W×H (mm) lets the server use the correct aspect ratio,
scaled to the same resolution as the inferred size.

- Bin editor dialog: W×H number inputs, pre-filled from saved phys_w/phys_h
- PUT /api/bins/:id/corners: accepts optional phys_w/phys_h; when provided,
  derives bin_w/bin_h from the physical aspect ratio at equivalent area
- phys_w/phys_h stored on the bin record for re-use on next edit
- future-plans.md: bin types note (reusable dimensions per model)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 05:13:57 +00:00
1ea14f8953 Fix bin editor handle hit-test mismatch
showModal() must be called before load_image() so the canvas has its
correct layout dimensions when parentElement.clientWidth is read.
Calling it after caused css_w to be computed against an unlaid-out
dialog, making drawn handle positions not match hit-test positions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 05:12:16 +00:00
c41fb42e16 Note: split CSS into per-section files with build-step consolidation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 05:06:00 +00:00
871ad7124a Add Images admin section for managing source image uses
New top-level nav section showing all source images in a list view
with checkboxes to edit the uses array (grid, bin) per image. Allows
correcting wrongly-tagged images without code changes.

Server PUT /api/source-images/:id was already in place; re-added the
frontend API wrapper that was prematurely removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 05:04:57 +00:00
53bd086661 Make source-use badges read-only display labels
Removing toggle interactivity from use badges — they were confusing
and the wrong place to manage uses. The uses array is now managed
automatically by upload context. Badges are plain spans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:41:44 +00:00
38c2d89c9b Add tabbed sub-views to bins section, create-bin-from-source flow
Bins section now mirrors the grids section with two tabs:
- Bins: gallery of processed bin records
- Sources: source images tagged with uses=['bin'], with upload and
  '+ Bin' button to create a bin record from an existing source image

Server: POST /api/bins/from-source accepts source_id, creates bin
record and adds 'bin' to the source image's uses array.

URL state: /bins → bins tab, /bins/sources → sources tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:41:08 +00:00
e183988acb Hide inactive source-use badges
Inactive badges (uses not present on a source image) are now hidden
rather than shown at low opacity, which was visually noisy and
confusing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:30:37 +00:00
28b4590903 Add bins feature: upload, de-perspective, gallery
- lib/storage.mjs: bin CRUD with bin: prefix
- lib/grid-image.mjs: compute_bin_size() capped at 1024px
- server.mjs: POST/GET/PUT/DELETE /api/bins routes; PUT /api/bins/:id/corners
  re-processes image via process_grid_image with rows=1 cols=1
- public/lib/api.mjs: bin API wrappers including upload_bin()
- public/index.html: Bins nav button
- public/templates.html: t-section-bins, t-bin-card, t-dialog-bin-editor
- public/app.mjs: render_bins(), open_bin_editor() using Grid_Setup,
  save/cancel wiring in init()
- public/style.css: bin gallery and card styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:28:03 +00:00
f370b6d48d Fix kv-store load to use try/catch instead of existsSync
Replace check-then-read with read-and-catch-ENOENT. The existsSync
pattern is redundant and slightly misleading; other errors still
propagate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:27:55 +00:00
67369b56be Add edge midpoint drag handles to Grid_Setup
Drag indices 4-7 correspond to top/right/bottom/left edge midpoints.
Dragging a midpoint applies the delta to both adjacent corners, making
it easier to align bins with rounded corners where corner handles may
be obscured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:27:35 +00:00
13 changed files with 2604 additions and 44 deletions

400
CLAUDE.md Normal file
View File

@@ -0,0 +1,400 @@
# CLAUDE.md — Electronics Inventory
Agent orientation file. Read this before touching code.
---
## What this project is
A self-hosted electronics inventory web app. Tracks components, PDFs/datasheets,
physical storage grids (photographed and de-perspectived), and bins. Single-user,
no auth. Node.js + Express 5 backend, vanilla JS SPA frontend, flat NDJSON
key-value store for persistence.
---
## File map
```
server.mjs Entry point. All Express routes. ~940 lines.
lib/
storage.mjs KV store CRUD wrappers (one section per entity type).
Read this to understand what data exists and its prefix.
kv-store.mjs Flat NDJSON key-value store (Simple_KeyValue_Store class).
Auto-loads on construct, auto-flushes with debounce.
grid-image.mjs Image processing: de-perspective a photo into a grid of
cell images using sharp. compute_bin_size() lives here.
ids.mjs generate_id() — timestamp-base36 + random suffix.
(Planned: migrate to sequential integers.)
public/
app.mjs SPA. All rendering, routing, dialog logic. ~2600 lines.
Sections separated by // --- comments.
lib/api.mjs All fetch wrappers. Read this for the full API surface.
lib/dom.mjs Tiny DOM helpers: qs(), clone(), show(), hide().
views/grid-setup.mjs Grid_Setup class — canvas corner editor with pan/zoom.
Used for both grid source images and bin corner editing.
templates.html All HTML templates (id="t-*"). Injected into body at init.
style.css All styles. Single file (planned: split per section).
index.html Shell. Loads app.mjs as module. Nav buttons hardcoded.
tools/
mv-sync.c / mv-sync renameat2(RENAME_NOREPLACE) binary for atomic rename
without overwrite. Used by settle_image_filename().
Makefile Builds mv-sync.
data/
inventory.ndjson The database. All entities in one flat KV file.
images/ All uploaded and processed images.
pdfs/ Uploaded PDF files.
thumbs/ PDF thumbnails (generated by pdftoppm).
```
---
## KV store — key prefixes
| Prefix | Entity | Storage function family |
|---------|---------------------|---------------------------|
| `f:` | Field definitions | get_field / set_field |
| `c:` | Components | get_component / set_component |
| `i:` | Inventory entries | get_inventory_entry / set_inventory_entry |
| `d:` | Grid drafts | get_grid_draft / set_grid_draft |
| `s:` | Source images | get_source_image / add_source_image |
| `g:` | Grid images | get_grid_image / set_grid_image |
| `ct:` | Component templates | get_component_template / set_component_template |
| `pdf:` | PDF files | get_pdf / set_pdf |
| `bt:` | Bin types | get_bin_type / set_bin_type |
| `bin:` | Bins | get_bin / set_bin |
All `list_*()` functions do a full-scan `startsWith(prefix)` over the store.
---
## Data model shapes
### Field definition (`f:`)
```js
{
id: string, // generate_id()
name: string, // e.g. 'resistance'
unit: string, // e.g. 'Ω' — optional
description: string,
created_at: number, // ms timestamp
}
```
### Component (`c:`)
```js
{
id: string,
name: string,
description: string,
fields: { [field_id]: string }, // values keyed by field definition id
images: string[], // filenames in data/images/
file_ids: string[], // linked PDF ids
created_at: number,
updated_at: number,
}
```
### Inventory entry (`i:`)
```js
{
id: string,
component_id: string,
location_type: 'physical' | 'bom' | 'digital' | 'grid',
location_ref: string, // free text for physical/bom/digital
quantity: string,
notes: string,
grid_id: string | null, // set when location_type === 'grid'
grid_row: number | null,
grid_col: number | null,
images: string[],
created_at: number,
updated_at: number,
}
```
### Source image (`s:`)
```js
{
id: string, // filename in data/images/ (used as key too)
original_name: string,
width: number,
height: number,
uses: ('grid' | 'bin')[], // which features reference this image
created_at: number,
}
```
### Grid draft (`d:`)
```js
{
id: string,
source_id: string, // source image filename
rows: number,
cols: number,
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
created_at: number,
updated_at: number,
}
```
### Grid image (`g:`) — result of processing a grid draft
```js
{
id: string,
source_id: string,
rows: number,
cols: number,
corners: [{x,y}, ...],
panels: [[{ filename, component_id?, notes? }, ...], ...], // [row][col]
created_at: number,
updated_at: number,
}
```
### Component template (`ct:`)
```js
{
id: string,
name: string,
formatter: string, // JS function body string, compiled at runtime
created_at: number,
updated_at: number,
}
```
### PDF (`pdf:`)
```js
{
id: string,
display_name: string,
filename: string, // in data/pdfs/
thumb_prefix: string, // in data/thumbs/ — pdftoppm output prefix
created_at: number,
}
```
### Bin type (`bt:`)
```js
{
id: string,
name: string,
phys_w: number, // mm
phys_h: number, // mm
description: string,
fields: { [field_id]: string },
created_at: number,
updated_at: number,
}
```
### Bin (`bin:`)
```js
{
id: string,
name: string,
type_id: string | null, // ref to bin type
source_id: string, // source image filename (always kept)
source_w: number,
source_h: number,
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
phys_w: number | null, // mm — null means infer from corners
phys_h: number | null,
image_filename: string | null, // processed output in data/images/; null if not yet processed
bin_w: number | null, // px dimensions of processed output
bin_h: number | null,
fields: { [field_id]: string },
contents: [ // embedded content items
{
id: string,
type: 'component' | 'item',
component_id: string | null, // set when type === 'component'
name: string | null, // set when type === 'item'
quantity: string,
notes: string,
created_at: number,
}
],
created_at: number,
updated_at: number,
}
```
---
## API routes (server.mjs)
All responses: `{ ok: true, ...data }` or `{ ok: false, error: string }`.
```
GET /api/fields
POST /api/fields body: { name, unit?, description? }
PUT /api/fields/:id
DELETE /api/fields/:id
GET /api/components
POST /api/components body: { name, description?, fields? }
GET /api/components/:id
PUT /api/components/:id body: { name?, description?, fields?, file_ids? }
DELETE /api/components/:id
POST /api/components/:id/images multipart: images[]
DELETE /api/components/:id/images/:img_id
GET /api/inventory
POST /api/inventory body: { component_id, location_type, ... }
PUT /api/inventory/:id
DELETE /api/inventory/:id
POST /api/inventory/:id/images multipart: images[]
DELETE /api/inventory/:id/images/:img_id
GET /api/grid-drafts
POST /api/grid-drafts body: { source_id, rows, cols }
PUT /api/grid-drafts/:id
DELETE /api/grid-drafts/:id
GET /api/source-images
POST /api/source-images multipart: images[] — creates source records (uses: ['grid'])
PUT /api/source-images/:id body: { uses }
DELETE /api/source-images/:id guarded: refused if any grid/bin still references it
GET /api/grid-images
GET /api/grid-images/:id
POST /api/grid-images body: { draft_id } — processes draft → grid image
PUT /api/grid-images/:id/panels/:pi body: { component_id?, notes? }
DELETE /api/grid-images/:id
GET /api/component-templates
POST /api/component-templates body: { name, formatter }
PUT /api/component-templates/:id
DELETE /api/component-templates/:id
GET /api/pdfs
POST /api/pdfs multipart: file, display_name, filename
PUT /api/pdfs/:id body: { display_name, filename }
DELETE /api/pdfs/:id guarded: refused if any component references it
GET /api/bin-types
POST /api/bin-types body: { name, phys_w, phys_h, description?, fields? }
PUT /api/bin-types/:id
DELETE /api/bin-types/:id guarded: refused if any bin references it
GET /api/bins
GET /api/bins/:id
POST /api/bins multipart: image, name?, type_id? — upload + create
POST /api/bins/from-source body: { source_id, name?, type_id? }
PUT /api/bins/:id body: { name?, type_id?, fields? }
PUT /api/bins/:id/corners body: { corners, phys_w?, phys_h? } — triggers reprocess
DELETE /api/bins/:id only deletes processed image_filename, not source
POST /api/bins/:id/contents body: { type, component_id?, name?, quantity, notes }
PUT /api/bins/:id/contents/:cid body: { quantity?, notes?, name? }
DELETE /api/bins/:id/contents/:cid
POST /api/maintenance/purge-missing-sources removes source KV entries whose files are gone
POST /api/maintenance/pdf-thumbs regenerates missing PDF thumbnails
```
---
## Frontend (app.mjs) structure
Module-level state variables at the top (`all_components`, `all_fields`, etc.).
All loaded once at startup via parallel API calls, mutated in place on changes.
**Key patterns:**
- `clone('t-template-id')` — clones a `<template>` into a live element
- `qs(el, '#id')` — scoped querySelector
- Dialog callbacks stored as module-level `let x_dialog_callback = null`,
set by the open function, called by the init-registered submit/save handler
- `render()` — top-level re-render, called after navigation or data changes
- `navigate(path)` — pushes history + calls render()
- `build_field_editor(rows_el, sel_el, new_btn_el, initial_fields)` — shared
helper that wires up field row editing; returns `{ get_fields() }`
**Section layout (by line range, approximate):**
```
190 Imports, state vars, startup data load
91250 Helper functions (field rendering, search, formatters)
251700 Components section (list, detail panel, edit dialog)
7011000 Inventory section
10011600 Grids section (list, draft editor, grid viewer)
16011900 Component templates, field definitions dialogs
19012000 Images admin section
20012450 Bins section (list, source list, types list, bin editor)
24502500 Routing (parse_url, navigate, render)
2500end init() — dialog injection, event handler registration
```
---
## Image file lifecycle
```
Upload → multer writes temp file to data/images/<generate_id()><ext>
→ settle_image_filename() renames to original filename using
rename_no_replace (atomic, no overwrite); falls back to temp name
→ source image KV record created with id = final filename
Grid processing:
source image + corners + rows/cols → sharp perspective transform
→ one cell image per panel → filenames stored in grid_image.panels[r][c]
Bin processing:
source image + corners → single de-perspectived image
→ stored as bin.image_filename (separate from bin.source_id)
→ source_id is never deleted when bin is deleted; only image_filename is
```
---
## Code style (owner preferences)
- **Indentation**: tabs, display width 4
- **Braces**: always, even single-line bodies — `if (x) { return; }`
- **Naming**: `lower_snek_case` functions/locals/singletons,
`CAPITAL_SNEK_CASE` top-level constants, `Title_Snek_Case` classes
- **Quotes**: single quotes preferred
- **Modules**: `.mjs` extension always
- **State**: stateful components must be classes; no bare module-level
variables for app state (module scope is for constants and exports only)
- **IDs**: prefer sequential integers over timestamp+random (migration pending)
- **No CDN URLs** in HTML; vendor libs via make build from node_modules
- **Error handling**: try/catch ENOENT, never existsSync-then-read (TOCTOU)
- **No over-engineering**: don't add helpers, abstractions, or error handling
for scenarios that can't happen; three similar lines beats a premature helper
---
## Git conventions (this project)
```
git config user.name 'mikael-lovqvists-claude-agent'
git config user.email 'mikaels.claude.agent@efforting.tech'
```
- Small, logical commits — one concern per commit
- Stage files explicitly by name, never `git add -A` or `git add .`
- Always `git status` and read every line before staging
- Commit message: imperative mood, explain *why* not *what*
---
## UI structure
See [`ui-structure.md`](ui-structure.md) for the full inventory of sections,
widget patterns, dialogs, templates, and a taxonomy of primitives relevant to
designing a higher-level UI representation.
---
## Known limitations / planned rewrite notes
See [`future-plans.md`](future-plans.md) for full detail. Key points relevant
to coding decisions:
- `app.mjs` will be split into per-section view modules
- KV store will become hierarchical (no more prefix convention)
- IDs will migrate to sequential integers
- CSS will be split per section with a build-step concatenation
- SSE for live updates across devices
- Generic fields on all entity types (already done for components, bins, bin types)
- A complete rewrite is planned; this codebase is the learning prototype

View File

@@ -7,12 +7,69 @@
`fs-views`, `publication-tool`). Should live in its own Gitea repo as an installable `fs-views`, `publication-tool`). Should live in its own Gitea repo as an installable
npm package (`npm install git+https://...`) so changes propagate rather than drift. npm package (`npm install git+https://...`) so changes propagate rather than drift.
### Hierarchical storage structure
The current store is a flat string→value map with prefixed keys (`f:`, `c:`, `bin:`,
etc.) as a manual namespacing convention. This should be replaced with a proper tree:
collections as top-level keys whose values are `Record<id, object>`. Eliminates the
prefix convention, makes collection access direct and self-documenting, and removes
the full-scan `startsWith` pattern from every `list_*` function. Requires a one-time
migration of existing NDJSON data. Best done as part of the shared library rewrite.
### Delta / revision tracking ### Delta / revision tracking
Add a delta log alongside the main snapshot file (e.g. `inventory.ndjson.deltas`) Add a delta log alongside the main snapshot file (e.g. `inventory.ndjson.deltas`)
that records every `set`/`delete` as a timestamped entry. The main file stays a that records every `set`/`delete` as a timestamped entry. The main file stays a
clean current-state snapshot; the delta file accumulates the full history. Enables clean current-state snapshot; the delta file accumulates the full history. Enables
undo, audit trails, and debugging data corruption. undo, audit trails, and debugging data corruption.
## Real-time / live updates
### Dual event bus architecture
Two independent buses, bridged by SSE:
**Server bus** (`lib/bus.mjs`, Node `EventEmitter`):
- Route handlers emit a specific event after each mutation (`field:deleted`,
`bin:changed`, etc.) and nothing else — side effects are not inline
- Effect modules in `server/effects/` subscribe and handle cascading work:
- `field-effects.mjs` — strips deleted field from all components/bins/bin types
- `sse-effects.mjs` — broadcasts mutations to connected SSE clients
- `audit-effects.mjs` — writes delta log (future)
- `bin-effects.mjs` — e.g. propagates type dimension changes to bins
- New cross-cutting concerns (audit, cache invalidation, notifications) are
additional listeners — route handlers never grow
**Client bus** (`lib/bus.mjs`, lightweight pub/sub or `EventTarget`):
- `api.mjs` emits on the bus after every successful mutation
- `sse.mjs` (SSE connection client) translates incoming server events to bus emits
- View modules subscribe to relevant events and re-render; they never call each other
- `mock-api.mjs` also emits on the same bus after in-memory mutations, so views
react correctly in mock mode without any SSE
**SSE bridge**: `sse-effects.mjs` on the server broadcasts to connected clients;
`sse.mjs` on the client receives and re-emits on the client bus. Views are unaware
of whether a change was local or remote.
**Avoiding wildcard listeners**: instead of a wildcard `*` listener (not natively
supported by `EventEmitter`), emit a generic `mutation` event alongside every
specific event. The SSE broadcaster listens to `mutation`; everything else listens
to specific events. New event types are automatically forwarded without touching
the broadcaster.
```js
function emit(event, data) {
bus.emit(event, data);
bus.emit('mutation', { event, data });
}
```
**Event granularity**: collection-level events are sufficient (`bins:changed`,
`components:changed`). Passing the affected id or record is optional — views can
use it to do a targeted update or ignore it and re-fetch the collection. Fine-grained
events are an optimisation to add later if full-collection re-fetches become slow.
Ties into the delta tracking plan: `audit-effects.mjs` is another bus listener —
the same mutation path that drives SSE also drives the delta log.
## App architecture ## App architecture
### parse_url mutates too many module-level variables ### parse_url mutates too many module-level variables
@@ -44,10 +101,46 @@ const SECTION_RENDERERS = {
function render() { sync_nav(); SECTION_RENDERERS[section]?.(); } function render() { sync_nav(); SECTION_RENDERERS[section]?.(); }
``` ```
### UI as a self-contained sub-project
The UI boundary is `api.mjs` — every piece of data the UI touches goes through
named exports in that file. This seam should be made explicit so the UI can be
developed and tested against a mock without a running server.
**Composition root / dependency injection**: `app.mjs` should not import `api.mjs`
directly. Instead it receives the api implementation as a parameter. Two thin entry
files wire it up:
```
main.mjs — imports real api.mjs, passes to app.start()
mock-main.mjs — imports mock-api.mjs, passes to app.start()
```
`mock-main.mjs` is a separate deployable (e.g. served at `/mock` or on a dev port),
not a URL flag. The app has no runtime knowledge of which implementation it received.
**mock-api.mjs**: same exports as `api.mjs`, backed by in-memory arrays seeded with
realistic fixture data. Mutations update the in-memory state so the UI behaves
realistically (add/delete/edit all persist within the session). Also emits on the
client bus so cross-view reactivity works identically to the real app. No SSE
connection needed in mock mode — the bus events come from the mock mutations.
**Views never call each other**: once split into modules, `views/bins.mjs` must
not import `views/inventory.mjs`. Cross-section reactions happen exclusively through
the client bus. This is the main structural discipline that makes the split work.
### app.mjs monolith ### app.mjs monolith
`app.mjs` is large. Consider splitting into per-section modules `app.mjs` is large. Split into per-section view modules (`views/components.mjs`,
(`views/components.mjs`, `views/grids.mjs`, etc.) that each export their render `views/grids.mjs`, `views/bins.mjs`, etc.) each owning its local state, subscribing
function and own their local state. to bus events at init, and exporting a single `mount(container)` function. The
composition root (`main.mjs`) imports all view modules and registers them.
### Split CSS into per-section files
`style.css` is a single large file and getting hard to navigate. Split into
per-section files (`components.css`, `grids.css`, `bins.css`, etc.) plus a
`base.css` for variables, resets, and shared layout. A `make build` step can
concatenate them into a single `style.css` for deployment, keeping the dev
experience clean without adding a bundler dependency.
### Explicit save in component editor ### Explicit save in component editor
Currently any change in the component detail panel (linking a file, unlinking an Currently any change in the component detail panel (linking a file, unlinking an
@@ -174,6 +267,13 @@ Some fields naturally belong together (e.g. `frequency_stability` and
Structured records are the more powerful option but require a schema system and Structured records are the more powerful option but require a schema system and
more complex UI. Grouping/linkage is a lighter short-term win. more complex UI. Grouping/linkage is a lighter short-term win.
As fields are shared across entity types (components, bins, bin types, and anything
else added later), the field pool grows to span unrelated domains. Groups also serve
as a domain filter in the field selector — when adding a field to a bin type, you
should be able to filter to e.g. "physical" or "storage" fields rather than seeing
electrical component fields mixed in. Each field should be able to belong to one or
more groups.
#### Semantically-aware formatting (acronyms, proper names) #### Semantically-aware formatting (acronyms, proper names)
Formatters that apply title case or similar text transformations can corrupt acronyms Formatters that apply title case or similar text transformations can corrupt acronyms
(e.g. `NPN``Npn`) or brand/proper names. The root cause is that free-text field (e.g. `NPN``Npn`) or brand/proper names. The root cause is that free-text field
@@ -289,10 +389,6 @@ slot targets) in addition to custom formatters.
This revision also applies to field parsers and search view expressions once those This revision also applies to field parsers and search view expressions once those
exist — they all follow the same pattern of JS function → structured output → exist — they all follow the same pattern of JS function → structured output →
context-specific renderer. context-specific renderer.
Any field that accepts JavaScript (name formatter templates, future custom search
views, field parsers, etc.) should use a CodeMirror 6 editor instead of a plain
`<textarea>`. Gives syntax highlighting, bracket matching, and a proper editing
experience for JS snippets.
## Search & views ## Search & views
@@ -374,6 +470,37 @@ used/visited locations at the top so you can quickly re-select where you just we
Useful when processing a batch of components into the same storage location — you Useful when processing a batch of components into the same storage location — you
shouldn't have to navigate the grid picker from scratch each time. shouldn't have to navigate the grid picker from scratch each time.
## Bins
### Bin types
Define reusable bin type records (e.g. "Sortimo L-Boxx insert small", "Wago 221
connector box") that store physical dimensions (mm), and optionally a default
compartment layout. When creating or editing a bin, the user picks a type and the
dimensions are pre-filled — no need to re-enter for every bin of the same model.
This also enables filtering/grouping bins by type, and makes it easy to re-process
all bins of a type if the corner algorithm improves.
### Generic fields on bins and bin types
Bins and bin types should both support the same generic field system as components —
arbitrary key/value pairs from the shared field definitions. Examples: color, material,
manufacturer, max load, purchase link. Bin types carry the "template" fields (e.g.
nominal dimensions from the datasheet) while individual bins carry instance-specific
fields (e.g. actual color of that specific unit).
Because fields are shared across components, bins, and anything else that grows into
the system, they will quickly span unrelated domains. Field grouping (see Field system
section) becomes important here so the field selector can be filtered to show only
relevant fields for the current entity type.
### Duplicate any entity
All objects in the system should be duplicatable: components, bin types, bins, grids,
templates, inventory entries, and eventually source images. The duplicate operation
creates a new record with all fields copied, then opens it in an edit dialog so the
user can adjust what differs. Bin type duplication is especially common — same
physical container model in different colors or configurations. Source images are a
later case since they reference uploaded files; duplication there would mean creating
a new metadata record pointing to the same underlying file (or an explicit copy).
## Grids ## Grids
### Grid view layers ### Grid view layers

View File

@@ -86,6 +86,22 @@ function bilinear_sample(pixels, width, height, x, y, out, out_idx) {
// Public API // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Compute natural size for a single de-perspectived bin image (cap at 1024px)
export function compute_bin_size(corners) {
const [tl, tr, br, bl] = corners;
const top_w = Math.hypot(tr.x - tl.x, tr.y - tl.y);
const bot_w = Math.hypot(br.x - bl.x, br.y - bl.y);
const left_h = Math.hypot(bl.x - tl.x, bl.y - tl.y);
const rgt_h = Math.hypot(br.x - tr.x, br.y - tr.y);
const raw_w = Math.max(top_w, bot_w);
const raw_h = Math.max(left_h, rgt_h);
const scale = Math.min(1.0, 1024 / Math.max(raw_w, raw_h));
return {
bin_w: Math.round(Math.max(48, raw_w * scale)),
bin_h: Math.round(Math.max(48, raw_h * scale)),
};
}
// Compute natural cell size from corner quadrilateral + grid dimensions // Compute natural cell size from corner quadrilateral + grid dimensions
export function compute_cell_size(corners, rows, cols) { export function compute_cell_size(corners, rows, cols) {
const [tl, tr, br, bl] = corners; const [tl, tr, br, bl] = corners;

View File

@@ -151,12 +151,14 @@ export class Simple_KeyValue_Store {
load() { load() {
const { data, storage_path } = this; const { data, storage_path } = this;
if (!fs.existsSync(storage_path)) { let file_contents;
return; try {
file_contents = fs.readFileSync(storage_path, 'utf-8');
} catch (e) {
if (e.code === 'ENOENT') { return; }
throw e;
} }
const file_contents = fs.readFileSync(storage_path, 'utf-8');
for (const line of file_contents.split('\n')) { for (const line of file_contents.split('\n')) {
if (!line) continue; if (!line) continue;
const [key, value] = JSON.parse(line); const [key, value] = JSON.parse(line);

View File

@@ -174,6 +174,50 @@ export function find_pdf_references(pdf_id) {
return list_components().filter(c => c.file_ids?.includes(pdf_id)); return list_components().filter(c => c.file_ids?.includes(pdf_id));
} }
// --- Bin types ---
export function list_bin_types() {
const result = [];
for (const [key] of store.data.entries()) {
if (key.startsWith('bt:')) result.push(store.get(key));
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
export function get_bin_type(id) {
return store.get(`bt:${id}`) ?? null;
}
export function set_bin_type(bt) {
store.set(`bt:${bt.id}`, bt);
}
export function delete_bin_type(id) {
return store.delete(`bt:${id}`);
}
// --- Bins ---
export function list_bins() {
const result = [];
for (const [key] of store.data.entries()) {
if (key.startsWith('bin:')) result.push(store.get(key));
}
return result.sort((a, b) => b.created_at - a.created_at);
}
export function get_bin(id) {
return store.get(`bin:${id}`) ?? null;
}
export function set_bin(bin) {
store.set(`bin:${bin.id}`, bin);
}
export function delete_bin(id) {
return store.delete(`bin:${id}`);
}
// --- Grid images --- // --- Grid images ---
export function list_grid_images() { export function list_grid_images() {

View File

@@ -28,13 +28,21 @@ let highlight_cell = null; // { row, col } — set when navigating from componen
let all_drafts = []; let all_drafts = [];
let all_templates = []; let all_templates = [];
let all_pdfs = []; let all_pdfs = [];
let all_bins = [];
let all_bin_types = [];
let bin_tab = 'bins'; // 'bins' | 'sources' | 'types'
let bin_editor_instance = null;
let bin_editor_bin_id = null;
let bin_editor_get_fields = null;
let bin_type_dialog_callback = null;
let bin_content_dialog_callback = null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data loading // Data loading
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function load_all() { async function load_all() {
const [cf, ci, cmp, gr, dr, sr, ct, pd] = await Promise.all([ const [cf, ci, cmp, gr, dr, sr, ct, pd, bn, bt] = await Promise.all([
api.get_fields(), api.get_fields(),
api.get_inventory(), api.get_inventory(),
api.get_components(), api.get_components(),
@@ -43,6 +51,8 @@ async function load_all() {
api.get_source_images(), api.get_source_images(),
api.get_component_templates(), api.get_component_templates(),
api.get_pdfs(), api.get_pdfs(),
api.get_bins(),
api.get_bin_types(),
]); ]);
all_fields = cf.fields; all_fields = cf.fields;
all_inventory = ci.entries; all_inventory = ci.entries;
@@ -52,6 +62,8 @@ async function load_all() {
all_sources = sr.sources; all_sources = sr.sources;
all_templates = ct.templates; all_templates = ct.templates;
all_pdfs = pd.pdfs; all_pdfs = pd.pdfs;
all_bins = bn.bins;
all_bin_types = bt.bin_types;
compile_templates(); compile_templates();
} }
@@ -149,6 +161,98 @@ function field_by_id(id) {
return all_fields.find(f => f.id === id); return all_fields.find(f => f.id === id);
} }
// Attaches a reusable field editor UI to existing DOM elements.
// Returns { get_fields() } — call get_fields() to collect trimmed non-empty values.
function build_field_editor(rows_el, add_sel_el, new_btn_el, initial_fields) {
const active = new Map(Object.entries(initial_fields ?? {}));
function rebuild_rows() {
const sorted = [...active.entries()].sort(([a], [b]) =>
(field_by_id(a)?.name ?? a).localeCompare(field_by_id(b)?.name ?? b));
rows_el.replaceChildren(...sorted.map(([fid, val]) => {
const def = field_by_id(fid);
const row = document.createElement('div');
row.className = 'c-field-input-row';
const lbl = document.createElement('div');
lbl.className = 'c-field-input-label';
lbl.textContent = def ? def.name : fid;
if (def?.unit) {
const u = document.createElement('span');
u.className = 'c-field-unit-hint';
u.textContent = `[${def.unit}]`;
lbl.appendChild(u);
}
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'c-field-value';
inp.value = val;
inp.autocomplete = 'off';
inp.dataset.field_id = fid;
inp.addEventListener('input', e => active.set(fid, e.target.value));
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn-icon btn-danger';
rm.textContent = '✕';
rm.title = 'Remove field';
rm.addEventListener('click', () => { active.delete(fid); rebuild_rows(); rebuild_sel(); });
row.append(lbl, inp, rm);
return row;
}));
}
function rebuild_sel() {
const available = all_fields.filter(f => !active.has(f.id));
add_sel_el.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— add a field —' }),
...available.map(f => Object.assign(document.createElement('option'), {
value: f.id, textContent: f.name + (f.unit ? ` [${f.unit}]` : ''),
}))
);
}
rebuild_rows();
rebuild_sel();
const prev = add_sel_el._field_handler;
if (prev) add_sel_el.removeEventListener('change', prev);
add_sel_el._field_handler = (e) => {
const fid = e.target.value;
if (!fid) return;
active.set(fid, '');
rebuild_rows();
rebuild_sel();
rows_el.querySelector(`[data-field_id="${fid}"]`)?.focus();
};
add_sel_el.addEventListener('change', add_sel_el._field_handler);
if (new_btn_el) {
new_btn_el.onclick = () => {
const known = new Set(all_fields.map(f => f.id));
open_field_dialog(null);
document.getElementById('dialog-field').addEventListener('close', () => {
const nf = all_fields.find(f => !known.has(f.id));
rebuild_sel();
if (nf) {
active.set(nf.id, '');
rebuild_rows();
rebuild_sel();
rows_el.querySelector(`[data-field_id="${nf.id}"]`)?.focus();
}
}, { once: true });
};
}
return {
get_fields() {
const out = {};
for (const [fid, val] of active.entries()) {
if (val.trim()) out[fid] = val.trim();
}
return out;
},
};
}
function matches_search(component, query) { function matches_search(component, query) {
if (!query) return true; if (!query) return true;
const words = query.toLowerCase().split(/\s+/).filter(Boolean); const words = query.toLowerCase().split(/\s+/).filter(Boolean);
@@ -893,6 +997,13 @@ function build_source_card(src, selectable, on_select = null) {
img_el.src = `/img/${src.id}`; img_el.src = `/img/${src.id}`;
qs(card, '.source-card-link').href = `/img/${src.id}`; qs(card, '.source-card-link').href = `/img/${src.id}`;
set_text(card, '.source-card-meta', [src.original_name, `${src.width}×${src.height}`].filter(Boolean).join(' · ')); set_text(card, '.source-card-meta', [src.original_name, `${src.width}×${src.height}`].filter(Boolean).join(' · '));
const uses_el = qs(card, '.source-card-uses');
for (const use of (src.uses ?? [])) {
const badge = document.createElement('span');
badge.className = `source-use-badge source-use-${use}`;
badge.textContent = use;
uses_el.appendChild(badge);
}
if (selectable) { if (selectable) {
card.classList.add('selectable'); card.classList.add('selectable');
@@ -1908,6 +2019,450 @@ function confirm_delete(message, on_confirm) {
dlg.showModal(); dlg.showModal();
} }
// ---------------------------------------------------------------------------
// Images (source image admin)
// ---------------------------------------------------------------------------
const KNOWN_USES = ['grid', 'bin'];
function render_images_section() {
const main = document.getElementById('main');
main.replaceChildren(document.getElementById('t-section-images').content.cloneNode(true));
const list_el = document.getElementById('img-admin-list');
if (all_sources.length === 0) {
const el = clone('t-empty-block');
el.textContent = 'No source images yet.';
list_el.appendChild(el);
return;
}
for (const src of all_sources) {
const row = clone('t-img-admin-row');
const link = qs(row, '.img-admin-thumb-link');
link.href = `/img/${src.id}`;
qs(row, '.img-admin-thumb').src = `/img/${src.id}`;
qs(row, '.img-admin-name').textContent = src.original_name || src.id;
qs(row, '.img-admin-meta').textContent = src.width && src.height ? `${src.width}×${src.height}` : '';
const uses_el = qs(row, '.img-admin-uses');
for (const use of KNOWN_USES) {
const label = document.createElement('label');
label.className = 'img-admin-use-label';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = src.uses?.includes(use) ?? false;
cb.addEventListener('change', async () => {
const new_uses = KNOWN_USES.filter(u => {
const el = uses_el.querySelector(`input[data-use="${u}"]`);
return el ? el.checked : src.uses?.includes(u);
});
try {
const result = await api.update_source_image_uses(src.id, new_uses);
src.uses = result.source.uses;
all_sources = all_sources.map(s => s.id === src.id ? { ...s, uses: src.uses } : s);
} catch (err) {
alert(err.message);
cb.checked = !cb.checked; // revert
}
});
cb.dataset.use = use;
label.appendChild(cb);
label.append(' ' + use);
uses_el.appendChild(label);
}
qs(row, '.img-admin-delete').addEventListener('click', async () => {
try {
await api.delete_source_image(src.id);
all_sources = all_sources.filter(s => s.id !== src.id);
row.remove();
} catch (err) {
alert(err.message);
}
});
list_el.appendChild(row);
}
}
// ---------------------------------------------------------------------------
// Bins
// ---------------------------------------------------------------------------
function render_bins() {
let sec = document.getElementById('section-bins');
if (!sec) {
const main = document.getElementById('main');
main.replaceChildren(document.getElementById('t-section-bins').content.cloneNode(true));
sec = document.getElementById('section-bins');
qs(sec, '#btn-tab-bins').addEventListener('click', () => {
bin_tab = 'bins';
history.replaceState(null, '', '/bins');
update_bin_tabs(sec);
});
qs(sec, '#btn-tab-bin-sources').addEventListener('click', () => {
bin_tab = 'sources';
history.replaceState(null, '', '/bins/sources');
update_bin_tabs(sec);
});
qs(sec, '#btn-tab-bin-types').addEventListener('click', () => {
bin_tab = 'types';
history.replaceState(null, '', '/bins/types');
update_bin_tabs(sec);
});
qs(sec, '#btn-add-bin-type').addEventListener('click', () => open_bin_type_dialog());
qs(sec, '#bin-source-upload-input').addEventListener('change', async (e) => {
const files = [...e.target.files];
if (!files.length) return;
for (const file of files) {
try {
const result = await api.upload_bin(file, file.name.replace(/\.[^.]+$/, ''));
all_bins.unshift(result.bin);
const src = all_sources.find(s => s.id === result.bin.source_id);
if (!src) {
const r2 = await api.get_source_images();
all_sources = r2.sources;
}
} catch (err) {
alert(err.message);
}
}
e.target.value = '';
render_bin_source_list();
});
}
update_bin_tabs(sec);
render_bin_list();
render_bin_source_list();
render_bin_types_list();
}
function update_bin_tabs(sec) {
qs(sec, '#btn-tab-bins').classList.toggle('active', bin_tab === 'bins');
qs(sec, '#btn-tab-bin-sources').classList.toggle('active', bin_tab === 'sources');
qs(sec, '#btn-tab-bin-types').classList.toggle('active', bin_tab === 'types');
qs(sec, '#btn-upload-bin-sources').hidden = (bin_tab !== 'sources');
qs(sec, '#btn-add-bin-type').hidden = (bin_tab !== 'types');
qs(sec, '#tab-bins-content').hidden = (bin_tab !== 'bins');
qs(sec, '#tab-bin-sources-content').hidden = (bin_tab !== 'sources');
qs(sec, '#tab-bin-types-content').hidden = (bin_tab !== 'types');
}
function render_bin_types_list() {
const list_el = document.getElementById('bin-types-list');
if (!list_el) return;
if (all_bin_types.length === 0) {
const el = clone('t-empty-block');
el.textContent = 'No bin types yet. Click "+ Add type" to define one.';
list_el.replaceChildren(el);
return;
}
list_el.replaceChildren(...all_bin_types.map(bt => {
const row = clone('t-bin-type-row');
qs(row, '.bin-type-name').textContent = bt.name;
qs(row, '.bin-type-dims').textContent = `${bt.phys_w} × ${bt.phys_h} mm`;
qs(row, '.bin-type-desc').textContent = bt.description || '';
qs(row, '.btn-edit').addEventListener('click', () => open_bin_type_dialog(bt));
qs(row, '.btn-delete').addEventListener('click', () => {
confirm_delete(`Delete bin type "${bt.name}"?`, async () => {
await api.delete_bin_type(bt.id);
all_bin_types = all_bin_types.filter(t => t.id !== bt.id);
render_bin_types_list();
});
});
return row;
}));
}
function open_bin_type_dialog(bt = null) {
const dlg = document.getElementById('dialog-bin-type');
dlg.querySelector('.dialog-title').textContent = bt ? 'Edit bin type' : 'Add bin type';
document.getElementById('bt-name').value = bt?.name ?? '';
document.getElementById('bt-width').value = bt?.phys_w ?? '';
document.getElementById('bt-height').value = bt?.phys_h ?? '';
document.getElementById('bt-description').value = bt?.description ?? '';
const { get_fields } = build_field_editor(
document.getElementById('bt-field-rows'),
document.getElementById('bt-add-field-select'),
document.getElementById('bt-new-field'),
bt?.fields ?? {}
);
bin_type_dialog_callback = async () => {
const name = document.getElementById('bt-name').value.trim();
const phys_w = parseFloat(document.getElementById('bt-width').value);
const phys_h = parseFloat(document.getElementById('bt-height').value);
const description = document.getElementById('bt-description').value.trim();
if (!name) { alert('Name is required.'); return; }
if (!(phys_w > 0) || !(phys_h > 0)) { alert('Dimensions must be positive numbers.'); return; }
const fields = get_fields();
if (bt) {
const r = await api.update_bin_type(bt.id, { name, phys_w, phys_h, description, fields });
all_bin_types = all_bin_types.map(t => t.id === bt.id ? r.bin_type : t);
} else {
const r = await api.create_bin_type({ name, phys_w, phys_h, description, fields });
all_bin_types = [...all_bin_types, r.bin_type].sort((a, b) => a.name.localeCompare(b.name));
}
render_bin_types_list();
};
dlg.showModal();
}
function render_bin_list() {
const gallery = document.getElementById('bin-gallery');
if (!gallery) return;
gallery.replaceChildren();
for (const bin of all_bins) {
const card = clone('t-bin-card');
const img = qs(card, '.bin-card-img');
const img_wrap = qs(card, '.bin-card-img-wrap');
qs(card, '.bin-card-name').textContent = bin.name;
if (bin.image_filename) {
img.src = `/img/${bin.image_filename}`;
img_wrap.classList.add('has-image');
} else {
img.hidden = true;
}
qs(card, '.btn-edit').addEventListener('click', () => open_bin_editor(bin));
qs(card, '.btn-delete').addEventListener('click', () => {
confirm_delete(`Delete bin "${bin.name}"?`, async () => {
await api.delete_bin(bin.id);
all_bins = all_bins.filter(b => b.id !== bin.id);
render_bin_list();
});
});
gallery.appendChild(card);
}
}
function render_bin_source_list() {
const list_el = document.getElementById('bin-source-image-list');
if (!list_el) return;
const bin_sources = all_sources.filter(s => s.uses?.includes('bin'));
if (bin_sources.length === 0) {
const el = clone('t-empty-block');
el.textContent = 'No bin source images yet. Upload photos of your bins.';
list_el.replaceChildren(el);
return;
}
list_el.replaceChildren(...bin_sources.map(src => {
const card = build_source_card(src, false);
const create_btn = card.querySelector('.source-card-create-bin');
create_btn.hidden = false;
create_btn.addEventListener('click', async () => {
try {
const result = await api.create_bin_from_source(src.id);
all_bins.unshift(result.bin);
render_bin_list();
open_bin_editor(result.bin);
bin_tab = 'bins';
update_bin_tabs(document.getElementById('section-bins'));
} catch (err) {
alert(err.message);
}
});
return card;
}));
}
function render_bin_contents(bin_id, container_el) {
const bin = all_bins.find(b => b.id === bin_id);
const contents = bin?.contents ?? [];
if (contents.length === 0) {
const empty = clone('t-empty-block');
empty.textContent = 'No items yet.';
container_el.replaceChildren(empty);
return;
}
container_el.replaceChildren(...contents.map(item => {
const row = clone('t-bin-content-row');
let display_name = item.name ?? '';
if (item.type === 'component') {
const comp = all_components.find(c => c.id === item.component_id);
display_name = comp ? component_display_name(comp) : `[unknown component]`;
}
qs(row, '.bin-content-name').textContent = display_name;
qs(row, '.bin-content-qty').textContent = item.quantity ? `×${item.quantity}` : '';
qs(row, '.bin-content-notes').textContent = item.notes || '';
qs(row, '.btn-edit').addEventListener('click', () => open_bin_content_dialog(bin_id, item));
qs(row, '.btn-delete').addEventListener('click', async () => {
try {
await api.delete_bin_content(bin_id, item.id);
const cur = all_bins.find(b => b.id === bin_id);
const updated = { ...cur, contents: cur.contents.filter(c => c.id !== item.id) };
all_bins = all_bins.map(b => b.id === bin_id ? updated : b);
render_bin_contents(bin_id, container_el);
} catch (err) {
alert(err.message);
}
});
return row;
}));
}
function open_bin_content_dialog(bin_id, item = null) {
const dlg = document.getElementById('dialog-bin-content');
qs(dlg, '.dialog-title').textContent = item ? 'Edit item' : 'Add item';
const type_sel = document.getElementById('bc-type');
const comp_row = document.getElementById('bc-component-row');
const name_row = document.getElementById('bc-name-row');
const comp_sel = document.getElementById('bc-component');
const name_inp = document.getElementById('bc-name');
const qty_inp = document.getElementById('bc-quantity');
const notes_inp = document.getElementById('bc-notes');
comp_sel.replaceChildren(
new Option('— select —', ''),
...all_components.map(c => new Option(component_display_name(c), c.id))
);
type_sel.value = item?.type ?? 'component';
comp_sel.value = item?.component_id ?? '';
name_inp.value = item?.name ?? '';
qty_inp.value = item?.quantity ?? '';
notes_inp.value = item?.notes ?? '';
type_sel.disabled = !!item;
function sync_type() {
const is_comp = type_sel.value === 'component';
comp_row.hidden = !is_comp;
name_row.hidden = is_comp;
}
type_sel.onchange = sync_type;
sync_type();
bin_content_dialog_callback = async () => {
const body = {
type: type_sel.value,
component_id: type_sel.value === 'component' ? comp_sel.value : undefined,
name: type_sel.value === 'item' ? name_inp.value.trim() : undefined,
quantity: qty_inp.value.trim(),
notes: notes_inp.value.trim(),
};
const result = item
? await api.update_bin_content(bin_id, item.id, body)
: await api.add_bin_content(bin_id, body);
all_bins = all_bins.map(b => b.id === bin_id ? result.bin : b);
render_bin_contents(bin_id, document.getElementById('bin-contents-list'));
};
dlg.showModal();
}
function open_bin_editor(bin) {
const dlg = document.getElementById('dialog-bin-editor');
if (!dlg) return;
bin_editor_bin_id = bin.id;
bin_editor_instance = null; // canvas not loaded until needed
document.getElementById('bin-editor-name').value = bin.name;
// Populate type selector
const type_sel = document.getElementById('bin-editor-type');
type_sel.replaceChildren(new Option('— Custom —', ''));
for (const bt of all_bin_types) {
type_sel.appendChild(new Option(`${bt.name} (${bt.phys_w}×${bt.phys_h}mm)`, bt.id));
}
type_sel.value = bin.type_id ?? '';
const dims_row = document.getElementById('bin-editor-dims-row');
function sync_dims_row() {
const bt = all_bin_types.find(t => t.id === type_sel.value);
if (bt) {
document.getElementById('bin-editor-width').value = bt.phys_w;
document.getElementById('bin-editor-height').value = bt.phys_h;
dims_row.hidden = true;
} else {
document.getElementById('bin-editor-width').value = bin.phys_w ?? '';
document.getElementById('bin-editor-height').value = bin.phys_h ?? '';
dims_row.hidden = false;
}
}
type_sel.addEventListener('change', sync_dims_row);
sync_dims_row();
// Image preview (default view)
const view_image = document.getElementById('bin-editor-view-image');
const view_corners = document.getElementById('bin-editor-view-corners');
const preview_img = document.getElementById('bin-editor-preview');
const no_image_el = document.getElementById('bin-editor-no-image');
function show_image_view() {
view_image.hidden = false;
view_corners.hidden = true;
}
function show_corners_view() {
view_image.hidden = true;
view_corners.hidden = false;
// Lazy-load canvas the first time
if (!bin_editor_instance) {
const canvas = document.getElementById('bin-editor-canvas');
bin_editor_instance = new Grid_Setup(canvas);
bin_editor_instance.set_rows(1);
bin_editor_instance.set_cols(1);
bin_editor_instance.load_image(`/img/${bin.source_id}`).then(() => {
if (bin.corners) {
bin_editor_instance.set_corners(bin.corners);
}
});
}
}
if (bin.image_filename) {
preview_img.src = `/img/${bin.image_filename}`;
preview_img.hidden = false;
no_image_el.hidden = true;
} else {
preview_img.hidden = true;
no_image_el.hidden = false;
}
document.getElementById('bin-editor-go-corners').onclick = () => {
dlg.showModal(); // already open; ensures layout is stable before canvas sizes
show_corners_view();
};
document.getElementById('bin-editor-go-back').onclick = show_image_view;
show_image_view();
// Tabs: Fields | Contents
const tab_panels = {
fields: document.getElementById('bin-editor-tab-fields'),
contents: document.getElementById('bin-editor-tab-contents'),
};
qs(dlg, '#bin-editor-tabs').onclick = (e) => {
const tab = e.target.dataset.tab;
if (!tab) return;
qs(dlg, '#bin-editor-tabs').querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
for (const [name, el] of Object.entries(tab_panels)) {
el.hidden = name !== tab;
}
};
// Fields
bin_editor_get_fields = build_field_editor(
document.getElementById('bin-field-rows'),
document.getElementById('bin-add-field-select'),
document.getElementById('bin-new-field'),
bin.fields ?? {}
).get_fields;
// Contents
render_bin_contents(bin.id, document.getElementById('bin-contents-list'));
document.getElementById('bin-add-content').onclick = () => open_bin_content_dialog(bin.id);
dlg.showModal();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Routing // Routing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1919,6 +2474,7 @@ function parse_url() {
section = 'components'; section = 'components';
grid_view_state = 'list'; grid_view_state = 'list';
grid_tab = 'grids'; grid_tab = 'grids';
bin_tab = 'bins';
current_grid_id = null; current_grid_id = null;
current_panel_idx = null; current_panel_idx = null;
grid_draft = null; grid_draft = null;
@@ -1941,6 +2497,11 @@ function parse_url() {
section = 'fields'; section = 'fields';
} else if (p0 === 'templates') { } else if (p0 === 'templates') {
section = 'templates'; section = 'templates';
} else if (p0 === 'images') {
section = 'images';
} else if (p0 === 'bins') {
section = 'bins';
bin_tab = p1 === 'sources' ? 'sources' : p1 === 'types' ? 'types' : 'bins';
} else if (p0 === 'grids') { } else if (p0 === 'grids') {
section = 'grids'; section = 'grids';
if (p1 === 'sources') { if (p1 === 'sources') {
@@ -2021,6 +2582,8 @@ function render() {
else if (section === 'fields') render_fields(); else if (section === 'fields') render_fields();
else if (section === 'grids') render_grids(); else if (section === 'grids') render_grids();
else if (section === 'templates') render_templates(); else if (section === 'templates') render_templates();
else if (section === 'bins') render_bins();
else if (section === 'images') render_images_section();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2031,7 +2594,7 @@ async function init() {
const html = await fetch('/templates.html').then(r => r.text()); const html = await fetch('/templates.html').then(r => r.text());
document.body.insertAdjacentHTML('beforeend', html); document.body.insertAdjacentHTML('beforeend', html);
for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker']) { for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker', 't-dialog-bin-editor', 't-dialog-bin-type', 't-dialog-bin-content']) {
document.body.appendChild(document.getElementById(id).content.cloneNode(true)); document.body.appendChild(document.getElementById(id).content.cloneNode(true));
} }
@@ -2122,6 +2685,64 @@ async function init() {
document.getElementById('dialog-confirm').close(); document.getElementById('dialog-confirm').close();
}); });
document.getElementById('bin-editor-cancel').addEventListener('click', () => {
document.getElementById('dialog-bin-editor').close();
bin_editor_instance = null;
bin_editor_bin_id = null;
});
document.getElementById('bt-cancel').addEventListener('click', () => {
document.getElementById('dialog-bin-type').close();
});
document.getElementById('bt-save').addEventListener('click', async () => {
try {
await bin_type_dialog_callback?.();
document.getElementById('dialog-bin-type').close();
} catch (err) {
alert(err.message);
}
});
document.getElementById('bin-editor-save').addEventListener('click', async () => {
const id = bin_editor_bin_id;
const name = document.getElementById('bin-editor-name').value.trim() || 'Bin';
const type_id = document.getElementById('bin-editor-type').value || null;
const fields = bin_editor_get_fields?.() ?? {};
try {
const corners = bin_editor_instance?.get_corners();
if (corners) {
// Canvas was opened — re-process corners too
const phys_w = parseFloat(document.getElementById('bin-editor-width').value) || null;
const phys_h = parseFloat(document.getElementById('bin-editor-height').value) || null;
await api.update_bin(id, { name, type_id, fields });
const r = await api.update_bin_corners(id, corners, phys_w, phys_h);
all_bins = all_bins.map(b => b.id === id ? r.bin : b);
} else {
const r = await api.update_bin(id, { name, type_id, fields });
all_bins = all_bins.map(b => b.id === id ? r.bin : b);
}
document.getElementById('dialog-bin-editor').close();
bin_editor_instance = null;
bin_editor_bin_id = null;
bin_editor_get_fields = null;
render_bins();
} catch (err) {
alert(err.message);
}
});
document.getElementById('bc-cancel').addEventListener('click', () => {
document.getElementById('dialog-bin-content').close();
});
document.getElementById('bc-save').addEventListener('click', async () => {
try {
await bin_content_dialog_callback?.();
document.getElementById('dialog-bin-content').close();
} catch (err) {
alert(err.message);
}
});
document.querySelectorAll('.nav-btn').forEach(btn => { document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => navigate('/' + btn.dataset.section)); btn.addEventListener('click', () => navigate('/' + btn.dataset.section));
}); });
@@ -2158,6 +2779,25 @@ async function init() {
} }
}); });
document.getElementById('maint-purge-sources').addEventListener('click', async () => {
maint_dropdown.hidden = true;
maint_toggle.textContent = '⏳';
maint_toggle.disabled = true;
try {
const result = await api.maintenance_purge_missing_sources();
if (result.removed.length > 0) {
all_sources = all_sources.filter(s => !result.removed.includes(s.id));
render();
}
alert(`Removed ${result.removed.length} orphaned entr${result.removed.length === 1 ? 'y' : 'ies'} of ${result.total} checked.`);
} catch (err) {
alert(`Error: ${err.message}`);
} finally {
maint_toggle.textContent = '⚙';
maint_toggle.disabled = false;
}
});
window.addEventListener('popstate', () => { parse_url(); render(); }); window.addEventListener('popstate', () => { parse_url(); render(); });
await load_all(); await load_all();

View File

@@ -16,11 +16,14 @@
<button class="nav-btn" data-section="fields">Fields</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="grids">Grids</button>
<button class="nav-btn" data-section="templates">Templates</button> <button class="nav-btn" data-section="templates">Templates</button>
<button class="nav-btn" data-section="bins">Bins</button>
<button class="nav-btn" data-section="images">Images</button>
</nav> </nav>
<div class="maint-menu" id="maint-menu"> <div class="maint-menu" id="maint-menu">
<button class="maint-toggle" id="maint-toggle" title="Maintenance"></button> <button class="maint-toggle" id="maint-toggle" title="Maintenance"></button>
<div class="maint-dropdown" id="maint-dropdown" hidden> <div class="maint-dropdown" id="maint-dropdown" hidden>
<button class="maint-item" id="maint-gen-thumbs">Generate missing PDF thumbnails</button> <button class="maint-item" id="maint-gen-thumbs">Generate missing PDF thumbnails</button>
<button class="maint-item" id="maint-purge-sources">Remove orphaned source image entries</button>
</div> </div>
</div> </div>
</header> </header>

View File

@@ -35,8 +35,9 @@ export const update_grid_draft = (id, body) => req('PUT', `/api/grid-drafts/${i
export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`); export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`);
// Source images // Source images
export const get_source_images = () => req('GET', '/api/source-images'); export const get_source_images = () => req('GET', '/api/source-images');
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`); export const update_source_image_uses = (id, uses) => req('PUT', `/api/source-images/${id}`, { uses });
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
// Component templates // Component templates
export const get_component_templates = () => req('GET', '/api/component-templates'); export const get_component_templates = () => req('GET', '/api/component-templates');
@@ -60,8 +61,36 @@ export async function upload_pdf(file, display_name, filename) {
return data; return data;
} }
// Bin types
export const get_bin_types = () => req('GET', '/api/bin-types');
export const create_bin_type = (body) => req('POST', '/api/bin-types', body);
export const update_bin_type = (id, body) => req('PUT', `/api/bin-types/${id}`, body);
export const delete_bin_type = (id) => req('DELETE', `/api/bin-types/${id}`);
// Bins
export const get_bins = () => req('GET', '/api/bins');
export const create_bin_from_source = (source_id, name) => req('POST', '/api/bins/from-source', { source_id, name });
export const get_bin = (id) => req('GET', `/api/bins/${id}`);
export const update_bin = (id, body) => req('PUT', `/api/bins/${id}`, body);
export const update_bin_corners = (id, corners, phys_w, phys_h) => req('PUT', `/api/bins/${id}/corners`, { corners, phys_w, phys_h });
export const add_bin_content = (id, body) => req('POST', `/api/bins/${id}/contents`, body);
export const update_bin_content = (id, cid, body) => req('PUT', `/api/bins/${id}/contents/${cid}`, body);
export const delete_bin_content = (id, cid) => req('DELETE', `/api/bins/${id}/contents/${cid}`);
export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`);
export async function upload_bin(file, name) {
const form = new FormData();
form.append('image', file);
if (name) form.append('name', name);
const res = await fetch('/api/bins', { method: 'POST', body: form });
const data = await res.json();
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
return data;
}
// Maintenance // Maintenance
export const maintenance_pdf_thumbs = () => req('POST', '/api/maintenance/pdf-thumbs'); export const maintenance_pdf_thumbs = () => req('POST', '/api/maintenance/pdf-thumbs');
export const maintenance_purge_missing_sources = () => req('POST', '/api/maintenance/purge-missing-sources');
// Grid images // Grid images
export const get_grids = () => req('GET', '/api/grid-images'); export const get_grids = () => req('GET', '/api/grid-images');

View File

@@ -1085,12 +1085,49 @@ nav {
box-shadow: 0 0 0 3px rgba(91, 156, 246, 0.3); box-shadow: 0 0 0 3px rgba(91, 156, 246, 0.3);
} }
.source-card-create-bin {
width: 100%;
}
.source-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
}
.source-card-meta { .source-card-meta {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-faint); color: var(--text-faint);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1;
}
.source-card-uses {
display: flex;
gap: 0.2rem;
flex-shrink: 0;
}
.source-use-badge {
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
text-transform: lowercase;
font-weight: 600;
letter-spacing: 0.02em;
}
.source-use-grid {
background: rgba(91, 156, 246, 0.18);
color: #5b9cf6;
}
.source-use-bin {
background: rgba(91, 246, 156, 0.18);
color: #5bf69c;
} }
.source-card-delete { .source-card-delete {
@@ -1806,3 +1843,266 @@ nav {
.detail-file-link:hover { .detail-file-link:hover {
text-decoration: underline; text-decoration: underline;
} }
/* ===== IMAGES ADMIN ===== */
.img-admin-list {
display: flex;
flex-direction: column;
gap: 0;
}
.img-admin-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.img-admin-thumb-link {
flex-shrink: 0;
}
.img-admin-thumb {
width: 80px;
height: 56px;
object-fit: cover;
border-radius: 3px;
display: block;
}
.img-admin-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.img-admin-name {
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.img-admin-meta {
font-size: 0.75rem;
color: var(--text-faint);
}
.img-admin-uses {
display: flex;
gap: 0.75rem;
}
.img-admin-use-label {
font-size: 0.8rem;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
}
/* ===== BINS ===== */
.bin-gallery {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem 0;
}
.bin-card {
width: 220px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.bin-card-img-wrap {
width: 100%;
aspect-ratio: 4 / 3;
background: #1a1a1a;
position: relative;
overflow: hidden;
}
.bin-card-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.bin-card-unprocessed {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--text-muted);
background: rgba(0,0,0,0.5);
}
.bin-card-img-wrap.has-image .bin-card-unprocessed {
display: none;
}
.bin-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.5rem;
gap: 0.5rem;
min-height: 2rem;
}
.bin-card-name {
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.bin-editor-dims {
display: flex;
align-items: center;
gap: 0.4rem;
}
.bin-editor-dims input {
width: 5rem;
}
.form-hint {
font-size: 0.78rem;
color: var(--text-faint);
margin-left: 0.25rem;
}
.bin-editor-preview-wrap {
width: 100%;
background: #0e0e0e;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
margin-bottom: 0.5rem;
overflow: hidden;
}
.bin-editor-preview-img {
max-width: 100%;
max-height: 55vh;
display: block;
object-fit: contain;
}
.bin-editor-no-image {
color: var(--text-faint);
font-size: 0.85rem;
padding: 2rem;
text-align: center;
}
.btn-link {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 0.25rem 0;
text-decoration: underline;
}
.btn-link:hover {
color: var(--text);
}
.bin-editor-canvas {
display: block;
border-radius: 4px;
max-width: 100%;
}
.bin-types-list {
display: flex;
flex-direction: column;
}
.bin-type-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
gap: 1rem;
}
.bin-type-info {
display: flex;
align-items: baseline;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.bin-type-name {
font-weight: 500;
}
.bin-type-dims {
font-size: 0.8rem;
color: var(--text-muted);
flex-shrink: 0;
}
.bin-type-desc {
font-size: 0.8rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bin-content-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
.bin-content-name {
flex: 1;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bin-content-qty {
font-size: 0.85rem;
color: var(--text-muted);
flex-shrink: 0;
}
.bin-content-notes {
font-size: 0.8rem;
color: var(--text-faint);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -248,6 +248,27 @@
<div class="empty-state"></div> <div class="empty-state"></div>
</template> </template>
<!-- ===== IMAGES SECTION ===== -->
<template id="t-section-images">
<section class="section" id="section-images">
<div class="img-admin-list" id="img-admin-list"></div>
</section>
</template>
<template id="t-img-admin-row">
<div class="img-admin-row">
<a class="img-admin-thumb-link" target="_blank" rel="noopener">
<img class="img-admin-thumb" alt="">
</a>
<div class="img-admin-info">
<div class="img-admin-name"></div>
<div class="img-admin-meta"></div>
<div class="img-admin-uses"></div>
</div>
<button type="button" class="btn-icon btn-danger img-admin-delete" title="Delete"></button>
</div>
</template>
<!-- ===== GRIDS SECTION ===== --> <!-- ===== GRIDS SECTION ===== -->
<template id="t-section-grids"> <template id="t-section-grids">
<section class="section" id="section-grids"> <section class="section" id="section-grids">
@@ -276,7 +297,11 @@
<a class="source-card-link" target="_blank" rel="noopener"> <a class="source-card-link" target="_blank" rel="noopener">
<img class="source-card-img" alt=""> <img class="source-card-img" alt="">
</a> </a>
<div class="source-card-meta"></div> <div class="source-card-footer">
<div class="source-card-meta"></div>
<div class="source-card-uses"></div>
</div>
<button type="button" class="btn btn-secondary btn-sm source-card-create-bin" hidden>+ Bin</button>
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete"></button> <button type="button" class="btn-icon btn-danger source-card-delete" title="Delete"></button>
</div> </div>
</template> </template>
@@ -585,6 +610,220 @@
</dialog> </dialog>
</template> </template>
<!-- ===== BINS SECTION ===== -->
<template id="t-section-bins">
<section class="section" id="section-bins">
<div class="section-toolbar">
<div class="tab-bar">
<button class="tab-btn" id="btn-tab-bins">Bins</button>
<button class="tab-btn" id="btn-tab-bin-sources">Source images</button>
<button class="tab-btn" id="btn-tab-bin-types">Types</button>
</div>
<label class="btn btn-secondary" id="btn-upload-bin-sources" hidden>
+ Upload
<input type="file" accept="image/*" multiple hidden id="bin-source-upload-input">
</label>
<button class="btn btn-primary" id="btn-add-bin-type" hidden>+ Add type</button>
</div>
<div id="tab-bins-content">
<div class="bin-gallery" id="bin-gallery"></div>
</div>
<div id="tab-bin-sources-content" hidden>
<div id="bin-source-image-list" class="source-gallery"></div>
</div>
<div id="tab-bin-types-content" hidden>
<div id="bin-types-list" class="bin-types-list"></div>
</div>
</section>
</template>
<template id="t-bin-type-row">
<div class="bin-type-row">
<div class="bin-type-info">
<span class="bin-type-name"></span>
<span class="bin-type-dims"></span>
<span class="bin-type-desc"></span>
</div>
<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>
</template>
<template id="t-bin-card">
<div class="bin-card">
<div class="bin-card-img-wrap">
<img class="bin-card-img" alt="">
<div class="bin-card-unprocessed">Not processed</div>
</div>
<div class="bin-card-footer">
<span class="bin-card-name"></span>
<span class="row-actions">
<button class="btn-icon btn-edit" title="Edit corners"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</div>
</div>
</template>
<!-- ===== DIALOG: BIN EDITOR ===== -->
<template id="t-dialog-bin-editor">
<dialog id="dialog-bin-editor" class="app-dialog app-dialog-wide">
<h2 class="dialog-title">Edit bin</h2>
<div class="form-row">
<label>Name</label>
<input type="text" id="bin-editor-name" autocomplete="off">
</div>
<div class="form-row">
<label>Type</label>
<select id="bin-editor-type">
<option value="">— Custom —</option>
</select>
</div>
<!-- Default view: processed image -->
<div id="bin-editor-view-image">
<div class="bin-editor-preview-wrap">
<img id="bin-editor-preview" class="bin-editor-preview-img" alt="" hidden>
<div id="bin-editor-no-image" class="bin-editor-no-image">Not yet processed — click "Adjust corners" to set up</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="bin-editor-go-corners">Adjust corners…</button>
</div>
<!-- Corners canvas (revealed on demand) -->
<div id="bin-editor-view-corners" hidden>
<div class="form-row" id="bin-editor-dims-row">
<label>Dimensions (mm)</label>
<div class="bin-editor-dims">
<input type="number" id="bin-editor-width" placeholder="W" min="1" step="1">
<span>×</span>
<input type="number" id="bin-editor-height" placeholder="H" min="1" step="1">
<span class="form-hint">Leave blank to infer from corners</span>
</div>
</div>
<canvas id="bin-editor-canvas" class="bin-editor-canvas"></canvas>
<button type="button" class="btn btn-link btn-sm" id="bin-editor-go-back">← Back to image</button>
</div>
<!-- Tabs: Fields | Contents -->
<div class="tab-bar" id="bin-editor-tabs">
<button class="tab-btn active" data-tab="fields">Fields</button>
<button class="tab-btn" data-tab="contents">Contents</button>
</div>
<div id="bin-editor-tab-fields">
<div id="bin-field-rows"></div>
<div class="form-row add-field-row">
<div class="input-with-action">
<select id="bin-add-field-select" class="filter-select">
<option value="">— add a field —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="bin-new-field">New…</button>
</div>
</div>
</div>
<div id="bin-editor-tab-contents" hidden>
<div id="bin-contents-list"></div>
<div class="form-row" style="margin-top:0.5rem">
<button type="button" class="btn btn-secondary btn-sm" id="bin-add-content">+ Add item</button>
</div>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="bin-editor-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="bin-editor-save">Save</button>
</div>
</dialog>
</template>
<!-- ===== TEMPLATE: BIN CONTENT ROW ===== -->
<template id="t-bin-content-row">
<div class="bin-content-row">
<span class="bin-content-name"></span>
<span class="bin-content-qty"></span>
<span class="bin-content-notes"></span>
<span class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Remove"></button>
</span>
</div>
</template>
<!-- ===== DIALOG: BIN CONTENT ITEM ===== -->
<template id="t-dialog-bin-content">
<dialog id="dialog-bin-content" class="app-dialog">
<h2 class="dialog-title">Add item</h2>
<div class="form-row">
<label>Type</label>
<select id="bc-type" class="filter-select wide">
<option value="component">Component</option>
<option value="item">Free text</option>
</select>
</div>
<div class="form-row" id="bc-component-row">
<label>Component</label>
<select id="bc-component" class="filter-select wide">
<option value="">— select —</option>
</select>
</div>
<div class="form-row" id="bc-name-row" hidden>
<label>Name</label>
<input type="text" id="bc-name" autocomplete="off">
</div>
<div class="form-row">
<label>Quantity</label>
<input type="text" id="bc-quantity" autocomplete="off" placeholder="e.g. 10, ~50, full">
</div>
<div class="form-row">
<label>Notes</label>
<input type="text" id="bc-notes" autocomplete="off" placeholder="Optional">
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="bc-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="bc-save">Save</button>
</div>
</dialog>
</template>
<template id="t-dialog-bin-type">
<dialog id="dialog-bin-type" class="app-dialog">
<h2 class="dialog-title"></h2>
<div class="form-row">
<label>Name</label>
<input type="text" id="bt-name" autocomplete="off" placeholder="e.g. Sortimo L-Boxx small">
</div>
<div class="form-row">
<label>Dimensions (mm)</label>
<div class="bin-editor-dims">
<input type="number" id="bt-width" placeholder="W" min="1" step="1">
<span>×</span>
<input type="number" id="bt-height" placeholder="H" min="1" step="1">
</div>
</div>
<div class="form-row">
<label>Description</label>
<input type="text" id="bt-description" autocomplete="off" placeholder="Optional">
</div>
<div class="form-section-label">Field values</div>
<div id="bt-field-rows"></div>
<div class="form-row add-field-row">
<div class="input-with-action">
<select id="bt-add-field-select" class="filter-select">
<option value="">— add a field —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="bt-new-field">New…</button>
</div>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="bt-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="bt-save">Save</button>
</div>
</dialog>
</template>
<!-- ===== CELL INVENTORY OVERLAY ===== --> <!-- ===== CELL INVENTORY OVERLAY ===== -->
<template id="t-cell-inventory"> <template id="t-cell-inventory">
<div class="cell-inventory-overlay" id="cell-inventory-overlay"> <div class="cell-inventory-overlay" id="cell-inventory-overlay">

View File

@@ -14,18 +14,22 @@ export class Grid_Setup {
#cam_z = 1; #cam_z = 1;
#corners = null; // in IMAGE coordinates #corners = null; // in IMAGE coordinates
#drag_idx = -1; // index of corner being dragged, or -1 #drag_idx = -1; // 0-3: corners, 4-7: edge midpoints (top,right,bottom,left)
#drag_prev_img = null; // previous image-space position for midpoint delta tracking
#panning = false; #panning = false;
#pan_last = { x: 0, y: 0 }; #pan_last = { x: 0, y: 0 };
#rows = 4; #rows = 4;
#cols = 6; #cols = 6;
// Edge pairs for midpoint handles: midpoint (idx-4) moves corners [a, b]
static #MIDPOINT_EDGES = [[0,1],[1,2],[2,3],[3,0]];
constructor(canvas_el) { constructor(canvas_el) {
this.#canvas = canvas_el; this.#canvas = canvas_el;
this.#ctx = canvas_el.getContext('2d'); this.#ctx = canvas_el.getContext('2d');
canvas_el.addEventListener('mousedown', e => this.#on_down(e)); canvas_el.addEventListener('mousedown', e => { e.preventDefault(); this.#on_down(e); });
canvas_el.addEventListener('mousemove', e => this.#on_move(e)); canvas_el.addEventListener('mousemove', e => this.#on_move(e));
canvas_el.addEventListener('mouseup', e => this.#on_up(e)); canvas_el.addEventListener('mouseup', e => this.#on_up(e));
canvas_el.addEventListener('mouseleave', () => { this.#drag_idx = -1; this.#panning = false; }); canvas_el.addEventListener('mouseleave', () => { this.#drag_idx = -1; this.#panning = false; });
@@ -41,32 +45,37 @@ export class Grid_Setup {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
this.#img = img; this.#img = img;
const max_w = this.#canvas.parentElement.clientWidth || 800;
const max_h = Math.floor(window.innerHeight * 0.65);
// Canvas fills the available space // Let CSS determine the width, then read back the actual rendered value.
this.#css_w = max_w; // Using parentElement.clientWidth directly would include the parent's
this.#css_h = max_h; // padding, causing css_w to exceed the real content area and making
// getBoundingClientRect() return a different width than css_w.
this.#canvas.style.width = '100%';
const css_w = this.#canvas.getBoundingClientRect().width || 800;
const css_h = Math.floor(window.innerHeight * 0.65);
this.#css_w = css_w;
this.#css_h = css_h;
// Scale: fit image within canvas with slight padding // Scale: fit image within canvas with slight padding
this.#scale = Math.min( this.#scale = Math.min(
(max_w * 0.9) / img.width, (css_w * 0.9) / img.width,
(max_h * 0.9) / img.height, (css_h * 0.9) / img.height,
); );
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
this.#canvas.width = this.#css_w * dpr; this.#canvas.width = css_w * dpr;
this.#canvas.height = this.#css_h * dpr; this.#canvas.height = css_h * dpr;
this.#canvas.style.width = this.#css_w + 'px'; this.#canvas.style.width = css_w + 'px';
this.#canvas.style.height = this.#css_h + 'px'; this.#canvas.style.height = css_h + 'px';
this.#ctx.scale(dpr, dpr); this.#ctx.scale(dpr, dpr);
// Camera: start centered, image fitted within canvas // Camera: start centered, image fitted within canvas
const img_w = img.width * this.#scale; const img_w = img.width * this.#scale;
const img_h = img.height * this.#scale; const img_h = img.height * this.#scale;
this.#cam_z = 1; this.#cam_z = 1;
this.#cam_x = (max_w - img_w) / 2; this.#cam_x = (css_w - img_w) / 2;
this.#cam_y = (max_h - img_h) / 2; this.#cam_y = (css_h - img_h) / 2;
// Default corners: 15% inset in image coords // Default corners: 15% inset in image coords
const mx = img.width * 0.15; const mx = img.width * 0.15;
@@ -126,25 +135,45 @@ export class Grid_Setup {
return { x: w.x * this.#cam_z + this.#cam_x, y: w.y * this.#cam_z + this.#cam_y }; return { x: w.x * this.#cam_z + this.#cam_x, y: w.y * this.#cam_z + this.#cam_y };
} }
#get_midpoints() {
return Grid_Setup.#MIDPOINT_EDGES.map(([a, b]) => ({
x: (this.#corners[a].x + this.#corners[b].x) / 2,
y: (this.#corners[a].y + this.#corners[b].y) / 2,
}));
}
#find_handle(sp, radius = 18) { #find_handle(sp, radius = 18) {
if (!this.#corners) return -1; if (!this.#corners) return -1;
// Corners take priority
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const s = this.#img_to_screen(this.#corners[i]); const s = this.#img_to_screen(this.#corners[i]);
if ((sp.x - s.x)**2 + (sp.y - s.y)**2 < radius**2) return i; if ((sp.x - s.x)**2 + (sp.y - s.y)**2 < radius**2) return i;
} }
// Midpoints
const mids = this.#get_midpoints();
for (let i = 0; i < 4; i++) {
const s = this.#img_to_screen(mids[i]);
if ((sp.x - s.x)**2 + (sp.y - s.y)**2 < (radius * 0.85)**2) return i + 4;
}
return -1; return -1;
} }
#on_down(e, is_touch = false) { #on_down(e, is_touch = false) {
if (!this.#corners) return; if (!this.#corners) return;
const sp = this.#screen_pos(e); const sp = this.#screen_pos(e);
if (!is_touch && e.button === 1) {
// Middle button: pan
e.preventDefault();
this.#panning = true;
this.#pan_last = sp;
this.#canvas.style.cursor = 'grabbing';
return;
}
if (!is_touch && e.button !== 0) { return; }
const hit = this.#find_handle(sp); const hit = this.#find_handle(sp);
if (hit !== -1) { if (hit !== -1) {
this.#drag_idx = hit; this.#drag_idx = hit;
} else { this.#drag_prev_img = this.#world_to_img(this.#to_world(sp));
this.#panning = true;
this.#pan_last = sp;
if (!is_touch) this.#canvas.style.cursor = 'grabbing';
} }
} }
@@ -152,7 +181,27 @@ export class Grid_Setup {
const sp = this.#screen_pos(e); const sp = this.#screen_pos(e);
if (this.#drag_idx !== -1) { if (this.#drag_idx !== -1) {
const img_pos = this.#world_to_img(this.#to_world(sp)); const img_pos = this.#world_to_img(this.#to_world(sp));
this.#corners[this.#drag_idx] = img_pos; const dx = img_pos.x - this.#drag_prev_img.x;
const dy = img_pos.y - this.#drag_prev_img.y;
if (this.#drag_idx < 4) {
const i = this.#drag_idx;
this.#corners[i] = { x: this.#corners[i].x + dx, y: this.#corners[i].y + dy };
} else {
const [a, b] = Grid_Setup.#MIDPOINT_EDGES[this.#drag_idx - 4];
// Project delta onto the edge's outward normal so the edge can
// only be pushed/pulled perpendicular to itself — never sheared.
const ex = this.#corners[b].x - this.#corners[a].x;
const ey = this.#corners[b].y - this.#corners[a].y;
const len = Math.sqrt(ex*ex + ey*ey);
if (len > 0) {
const nx = -ey / len;
const ny = ex / len;
const proj = dx * nx + dy * ny;
this.#corners[a] = { x: this.#corners[a].x + nx * proj, y: this.#corners[a].y + ny * proj };
this.#corners[b] = { x: this.#corners[b].x + nx * proj, y: this.#corners[b].y + ny * proj };
}
}
this.#drag_prev_img = img_pos;
this.#draw(); this.#draw();
} else if (this.#panning) { } else if (this.#panning) {
this.#cam_x += sp.x - this.#pan_last.x; this.#cam_x += sp.x - this.#pan_last.x;
@@ -167,6 +216,7 @@ export class Grid_Setup {
#on_up(e) { #on_up(e) {
this.#drag_idx = -1; this.#drag_idx = -1;
this.#drag_prev_img = null;
if (this.#panning) { if (this.#panning) {
this.#panning = false; this.#panning = false;
const sp = this.#screen_pos(e); const sp = this.#screen_pos(e);
@@ -255,6 +305,19 @@ export class Grid_Setup {
ctx.fillText(LABELS[i], pt.x, pt.y); ctx.fillText(LABELS[i], pt.x, pt.y);
}); });
// Midpoint handles — smaller, white with blue stroke
const mid_r = 7 / this.#cam_z;
const mids = this.#get_midpoints().map(m => this.#img_to_world(m));
mids.forEach(pt => {
ctx.beginPath();
ctx.arc(pt.x, pt.y, mid_r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.fill();
ctx.strokeStyle = 'rgba(91,156,246,0.9)';
ctx.lineWidth = 2 / this.#cam_z;
ctx.stroke();
});
ctx.restore(); ctx.restore();
} }
} }

View File

@@ -3,12 +3,12 @@ process.on('uncaughtException', (err) => { console.error('[uncaughtException
import express from 'express'; import express from 'express';
import multer from 'multer'; import multer from 'multer';
import { unlinkSync, mkdirSync } from 'node:fs'; import { unlinkSync, mkdirSync, existsSync } from 'node:fs';
import { extname, join } from 'node:path'; import { extname, join } from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import sharp from 'sharp'; import sharp from 'sharp';
import { generate_id } from './lib/ids.mjs'; import { generate_id } from './lib/ids.mjs';
import { compute_cell_size, process_grid_image } from './lib/grid-image.mjs'; import { compute_cell_size, compute_bin_size, process_grid_image } from './lib/grid-image.mjs';
import { import {
list_fields, get_field, set_field, delete_field, list_fields, get_field, set_field, delete_field,
list_components, get_component, set_component, delete_component, list_components, get_component, set_component, delete_component,
@@ -18,11 +18,36 @@ import {
list_grid_images, get_grid_image, set_grid_image, delete_grid_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, list_component_templates, get_component_template, set_component_template, delete_component_template,
list_pdfs, get_pdf, set_pdf, delete_pdf, list_pdfs, get_pdf, set_pdf, delete_pdf,
list_bins, get_bin, set_bin, delete_bin,
list_bin_types, get_bin_type, set_bin_type, delete_bin_type,
} from './lib/storage.mjs'; } from './lib/storage.mjs';
mkdirSync('./data/images', { recursive: true }); mkdirSync('./data/images', { recursive: true });
mkdirSync('./data/pdfs', { recursive: true }); mkdirSync('./data/pdfs', { recursive: true });
// Migration: backfill uses[] on existing source images, and register any bin
// sources that predate the unified gallery.
(function migrate_source_images() {
for (const src of list_source_images()) {
if (!src.uses) {
add_source_image({ ...src, uses: ['grid'] });
}
}
for (const bin of list_bins()) {
if (!get_source_image(bin.source_id)) {
// Source image missing from KV — re-register it
add_source_image({
id: bin.source_id,
original_name: '',
width: bin.source_w ?? 0,
height: bin.source_h ?? 0,
uses: ['bin'],
created_at: bin.created_at,
});
}
}
}());
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use('/img', express.static('./data/images')); app.use('/img', express.static('./data/images'));
@@ -63,6 +88,17 @@ function remove_image_file(img_id) {
try { unlinkSync(join('./data/images', img_id)); } catch {} try { unlinkSync(join('./data/images', img_id)); } catch {}
} }
// Try to rename temp file to the original name; falls back to temp name on collision.
// Returns the final filename (basename only).
function settle_image_filename(temp_filename, original_name) {
const preferred = original_name || temp_filename;
const dest = join('./data/images', preferred);
if (rename_no_replace(join('./data/images', temp_filename), dest)) {
return preferred;
}
return temp_filename;
}
const MV_SYNC = new URL('./tools/mv-sync', import.meta.url).pathname; const MV_SYNC = new URL('./tools/mv-sync', import.meta.url).pathname;
// Atomically rename src -> dst, failing if dst already exists. // Atomically rename src -> dst, failing if dst already exists.
@@ -328,12 +364,14 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
if (!req.files?.length) return fail(res, 'no files'); if (!req.files?.length) return fail(res, 'no files');
const added = []; const added = [];
for (const file of req.files) { for (const file of req.files) {
const meta = await sharp(file.path).metadata(); const final_name = settle_image_filename(file.filename, file.originalname);
const meta = await sharp(join('./data/images', final_name)).metadata();
const src = { const src = {
id: file.filename, id: final_name,
original_name: file.originalname, original_name: file.originalname,
width: meta.width, width: meta.width,
height: meta.height, height: meta.height,
uses: ['grid'],
created_at: Date.now(), created_at: Date.now(),
}; };
add_source_image(src); add_source_image(src);
@@ -342,15 +380,26 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
ok(res, { sources: added }); ok(res, { sources: added });
}); });
app.put('/api/source-images/:id', (req, res) => {
const src = get_source_image(req.params.id);
if (!src) return fail(res, 'not found', 404);
const { uses } = req.body;
if (!Array.isArray(uses)) return fail(res, 'uses must be an array');
const updated = { ...src, uses };
add_source_image(updated);
ok(res, { source: updated });
});
app.delete('/api/source-images/:id', (req, res) => { app.delete('/api/source-images/:id', (req, res) => {
const id = req.params.id; const id = req.params.id;
if (!get_source_image(id)) return fail(res, 'not found', 404); if (!get_source_image(id)) return fail(res, 'not found', 404);
const grids = list_grid_images(); const in_grid = list_grid_images().find(g =>
const in_use = grids.find(g =>
g.source_id === id || g.source_id === id ||
(g.panels && g.panels.some(p => p.source_id === id)) (g.panels && g.panels.some(p => p.source_id === id))
); );
if (in_use) return fail(res, `In use by grid "${in_use.name}"`, 409); if (in_grid) return fail(res, `In use by grid "${in_grid.name}"`, 409);
const in_bin = list_bins().find(b => b.source_id === id);
if (in_bin) return fail(res, `In use by bin "${in_bin.name}"`, 409);
remove_image_file(id); remove_image_file(id);
delete_source_image(id); delete_source_image(id);
ok(res); ok(res);
@@ -585,6 +634,18 @@ app.delete('/api/pdfs/:id', (req, res) => {
// Maintenance // Maintenance
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
app.post('/api/maintenance/purge-missing-sources', (req, res) => {
const sources = list_source_images();
const removed = [];
for (const src of sources) {
if (!existsSync(join('./data/images', src.id))) {
delete_source_image(src.id);
removed.push(src.id);
}
}
ok(res, { removed, total: sources.length });
});
app.post('/api/maintenance/pdf-thumbs', (req, res) => { app.post('/api/maintenance/pdf-thumbs', (req, res) => {
const pdfs = list_pdfs(); const pdfs = list_pdfs();
let generated = 0; let generated = 0;
@@ -600,6 +661,280 @@ app.post('/api/maintenance/pdf-thumbs', (req, res) => {
ok(res, { generated, total: pdfs.length }); ok(res, { generated, total: pdfs.length });
}); });
// ---------------------------------------------------------------------------
// Bin types
// ---------------------------------------------------------------------------
app.get('/api/bin-types', (req, res) => {
ok(res, { bin_types: list_bin_types() });
});
app.post('/api/bin-types', (req, res) => {
const { name, phys_w, phys_h, description = '', fields = {} } = req.body;
if (!name?.trim()) return fail(res, 'name is required');
if (!(phys_w > 0)) return fail(res, 'phys_w must be a positive number');
if (!(phys_h > 0)) return fail(res, 'phys_h must be a positive number');
const bt = {
id: generate_id(),
name: name.trim(),
phys_w: Number(phys_w),
phys_h: Number(phys_h),
description: description.trim(),
fields: typeof fields === 'object' && fields !== null ? fields : {},
created_at: Date.now(),
updated_at: Date.now(),
};
set_bin_type(bt);
ok(res, { bin_type: bt });
});
app.put('/api/bin-types/:id', (req, res) => {
const existing = get_bin_type(req.params.id);
if (!existing) return fail(res, 'not found', 404);
const { name, phys_w, phys_h, description, fields } = req.body;
const updated = { ...existing, updated_at: Date.now() };
if (name !== undefined) updated.name = name.trim();
if (phys_w !== undefined) {
if (!(Number(phys_w) > 0)) return fail(res, 'phys_w must be a positive number');
updated.phys_w = Number(phys_w);
}
if (phys_h !== undefined) {
if (!(Number(phys_h) > 0)) return fail(res, 'phys_h must be a positive number');
updated.phys_h = Number(phys_h);
}
if (description !== undefined) updated.description = description.trim();
if (fields !== undefined && typeof fields === 'object' && fields !== null) updated.fields = fields;
set_bin_type(updated);
ok(res, { bin_type: updated });
});
app.delete('/api/bin-types/:id', (req, res) => {
if (!get_bin_type(req.params.id)) return fail(res, 'not found', 404);
const in_use = list_bins().find(b => b.type_id === req.params.id);
if (in_use) return fail(res, `In use by bin "${in_use.name}"`, 409);
delete_bin_type(req.params.id);
ok(res);
});
// ---------------------------------------------------------------------------
// Bins
// ---------------------------------------------------------------------------
app.get('/api/bins', (req, res) => {
ok(res, { bins: list_bins() });
});
app.get('/api/bins/:id', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
ok(res, { bin });
});
// Create a bin from an already-uploaded source image
app.post('/api/bins/from-source', (req, res) => {
const { source_id, name = '', type_id = null } = req.body;
if (!source_id) return fail(res, 'source_id is required');
const src = get_source_image(source_id);
if (!src) return fail(res, 'source image not found', 404);
if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404);
if (!src.uses?.includes('bin')) {
add_source_image({ ...src, uses: [...(src.uses ?? []), 'bin'] });
}
const bt = type_id ? get_bin_type(type_id) : null;
const mx = Math.round(src.width * 0.15);
const my = Math.round(src.height * 0.15);
const bin = {
id: generate_id(),
name: name.trim() || src.original_name?.replace(/\.[^.]+$/, '') || 'Bin',
type_id,
source_id,
source_w: src.width,
source_h: src.height,
corners: [
{ x: mx, y: my },
{ x: src.width - mx, y: my },
{ x: src.width - mx, y: src.height - my },
{ x: mx, y: src.height - my },
],
phys_w: bt?.phys_w ?? null,
phys_h: bt?.phys_h ?? null,
image_filename: null,
fields: {},
contents: [],
created_at: Date.now(),
updated_at: Date.now(),
};
set_bin(bin);
ok(res, { bin });
});
// Upload a source image for a bin and create the bin record (no processing yet)
app.post('/api/bins', upload.single('image'), async (req, res) => {
if (!req.file) return fail(res, 'no image uploaded');
const { name = '', type_id = null } = req.body;
if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404);
const final_name = settle_image_filename(req.file.filename, req.file.originalname);
const meta = await sharp(join('./data/images', final_name)).metadata();
add_source_image({
id: final_name,
original_name: req.file.originalname,
width: meta.width,
height: meta.height,
uses: ['bin'],
created_at: Date.now(),
});
const bt = type_id ? get_bin_type(type_id) : null;
const mx = Math.round(meta.width * 0.15);
const my = Math.round(meta.height * 0.15);
const bin = {
id: generate_id(),
name: name.trim() || 'Bin',
type_id,
source_id: final_name,
source_w: meta.width,
source_h: meta.height,
corners: [
{ x: mx, y: my },
{ x: meta.width - mx, y: my },
{ x: meta.width - mx, y: meta.height - my },
{ x: mx, y: meta.height - my },
],
phys_w: bt?.phys_w ?? null,
phys_h: bt?.phys_h ?? null,
image_filename: null,
fields: {},
contents: [],
created_at: Date.now(),
updated_at: Date.now(),
};
set_bin(bin);
ok(res, { bin });
});
// Update corners (and re-process image)
app.put('/api/bins/:id/corners', async (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const { corners, phys_w, phys_h } = req.body;
if (!corners || corners.length !== 4) return fail(res, 'corners must be array of 4 points');
try {
if (bin.image_filename) remove_image_file(bin.image_filename);
const source_path = join('./data/images', bin.source_id);
let bin_w, bin_h;
if (phys_w > 0 && phys_h > 0) {
// Use physical aspect ratio scaled to the same area as computed size
const computed = compute_bin_size(corners);
const area = computed.bin_w * computed.bin_h;
const aspect = phys_w / phys_h;
bin_h = Math.round(Math.sqrt(area / aspect));
bin_w = Math.round(bin_h * aspect);
} else {
({ bin_w, bin_h } = compute_bin_size(corners));
}
const cells = await process_grid_image(source_path, corners, 1, 1, bin_w, bin_h, './data/images');
const image_filename = cells[0][0];
const updated = {
...bin, corners, image_filename, bin_w, bin_h,
phys_w: phys_w > 0 ? phys_w : (bin.phys_w ?? null),
phys_h: phys_h > 0 ? phys_h : (bin.phys_h ?? null),
updated_at: Date.now(),
};
set_bin(updated);
ok(res, { bin: updated });
} catch (err) {
console.error(err);
fail(res, err.message, 500);
}
});
// Update name / type / fields
app.put('/api/bins/:id', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const { name, type_id, fields } = req.body;
if (type_id !== undefined && type_id !== null && !get_bin_type(type_id)) {
return fail(res, 'bin type not found', 404);
}
const updated = { ...bin, updated_at: Date.now() };
if (name !== undefined) updated.name = name.trim() || 'Bin';
if (type_id !== undefined) {
updated.type_id = type_id;
const bt = type_id ? get_bin_type(type_id) : null;
if (bt) { updated.phys_w = bt.phys_w; updated.phys_h = bt.phys_h; }
}
if (fields !== undefined && typeof fields === 'object' && fields !== null) updated.fields = fields;
set_bin(updated);
ok(res, { bin: updated });
});
// Add a content item to a bin
app.post('/api/bins/:id/contents', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const { type, component_id, name, quantity = '', notes = '' } = req.body;
if (type !== 'component' && type !== 'item') return fail(res, 'type must be "component" or "item"');
if (type === 'component' && !component_id) return fail(res, 'component_id is required for type component');
if (type === 'item' && !name?.trim()) return fail(res, 'name is required for type item');
const item = {
id: generate_id(),
type,
component_id: type === 'component' ? component_id : null,
name: type === 'item' ? name.trim() : null,
quantity: String(quantity).trim(),
notes: String(notes).trim(),
created_at: Date.now(),
};
const updated = { ...bin, contents: [...(bin.contents ?? []), item], updated_at: Date.now() };
set_bin(updated);
ok(res, { bin: updated, item });
});
// Update a content item
app.put('/api/bins/:id/contents/:cid', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const idx = (bin.contents ?? []).findIndex(c => c.id === req.params.cid);
if (idx === -1) return fail(res, 'content item not found', 404);
const item = bin.contents[idx];
const { quantity, notes, name } = req.body;
const updated_item = { ...item };
if (quantity !== undefined) updated_item.quantity = String(quantity).trim();
if (notes !== undefined) updated_item.notes = String(notes).trim();
if (name !== undefined && item.type === 'item') updated_item.name = name.trim();
const new_contents = bin.contents.map((c, i) => i === idx ? updated_item : c);
const updated = { ...bin, contents: new_contents, updated_at: Date.now() };
set_bin(updated);
ok(res, { bin: updated, item: updated_item });
});
// Remove a content item
app.delete('/api/bins/:id/contents/:cid', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const exists = (bin.contents ?? []).some(c => c.id === req.params.cid);
if (!exists) return fail(res, 'content item not found', 404);
const updated = { ...bin, contents: bin.contents.filter(c => c.id !== req.params.cid), updated_at: Date.now() };
set_bin(updated);
ok(res);
});
app.delete('/api/bins/:id', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
// Only delete the processed output — source image is managed independently
if (bin.image_filename) remove_image_file(bin.image_filename);
delete_bin(bin.id);
ok(res);
});
// SPA fallback — serve index.html for any non-API, non-asset path // SPA fallback — serve index.html for any non-API, non-asset path
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname; const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML)); app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));

362
ui-structure.md Normal file
View File

@@ -0,0 +1,362 @@
# UI Structure
Current UI inventory — sections, widgets, templates, and recurring patterns.
Intended as a reference for the rewrite and for defining a higher-level UI
representation.
---
## Navigation
Top bar with 7 section buttons + a maintenance dropdown (⚙):
| Button | Section | Default? |
|--------|---------|----------|
| Components | Split-pane list/detail | ✓ |
| Inventory | Table | |
| Fields | Table | |
| Grids | Tabbed (Grids / Source images) + sub-views | |
| Templates | Card list | |
| Bins | Tabbed (Bins / Source images / Types) | |
| Images | Admin list | |
Maintenance dropdown (not a section):
- Generate missing PDF thumbnails
- Remove orphaned source image entries
---
## Sections
### Components
**Layout:** Resizable split-pane (width persisted in localStorage)
**Left — master list:**
- Search input (filters by name + field values)
- Quick-add input (creates component inline)
- List of `t-component-row` items (name + colored field-value badge tags)
**Right — detail panel:**
- Placeholder when nothing selected
- When selected: name, description, Edit / Duplicate / Delete actions
- Fields block — name/value rows
- Images block — thumbnail gallery + upload button
- Files block — linked PDFs + file picker button
- Inventory block — location entries (each with edit/delete) + add button
**Dialogs opened from here:** component edit, field create, inventory entry, file
picker, confirm delete
---
### Inventory
**Layout:** Toolbar + table
- Filter: text search + location-type dropdown (All / Physical / BOM / Digital / Grid)
- Table columns: Component | Type | Location/Ref | Qty | Notes | Actions (edit, delete)
- Component name cells are links → `/components/:id`
- Grid location cells link to the grid viewer at that cell
- "+ Add entry" button in toolbar
**Dialogs opened from here:** inventory entry edit/create, confirm delete
---
### Fields
**Layout:** Toolbar + table
- Table columns: Name | Unit | Description | Actions (edit, delete)
- "+ Add field" button in toolbar
**Dialogs opened from here:** field edit/create, confirm delete
---
### Templates
**Layout:** Toolbar + card list
- Cards show: name, formatter JS code (read-only `<pre>`), edit/delete actions
- "+ Add template" button
- Live preview panel in the edit dialog
**Dialogs opened from here:** template edit/create, confirm delete
---
### Grids
**Layout:** Tabbed, plus three full-page sub-views (not dialogs)
**Tab: Grids**
- Grid cards (image grid preview, name, delete)
- Draft cards (unprocessed setups, badge "Draft")
- "+ New grid" button → wizard dialog
**Tab: Source images**
- Source image gallery (`t-source-card`)
- "+ Upload" button
**Sub-view: Panel manager** (replaces main content)
- Editable grid of panel slots — each slot is a photo region
- Header with grid name, Cancel / Process buttons
**Sub-view: Grid setup** (replaces main content)
- Canvas corner editor (`Grid_Setup` class) with pan/zoom
- Right panel: cell size info, action buttons, progress
**Sub-view: Grid viewer** (replaces main content)
- Full grid of cell thumbnails
- Click cell → cell inventory overlay (non-modal panel)
- Header with Edit panels / Delete actions
**Dialogs opened from here:** new grid wizard, source picker, confirm delete
---
### Bins
**Layout:** Tabbed
**Tab: Bins**
- Gallery of `t-bin-card` items (processed image or "Not processed" indicator)
- Click Edit (✎) → bin editor dialog
**Tab: Source images**
- Filtered source gallery (uses includes 'bin')
- Each card has a "+ Bin" button to create a bin from that source
**Tab: Types**
- List of `t-bin-type-row` items (name, dimensions, description)
- Edit / delete per row
- "+ Add type" button
**Dialogs opened from here:** bin editor, bin type edit/create, bin content
add/edit, field create, confirm delete
---
### Images
**Layout:** Admin list
- One row per source image: thumbnail, filename, dimensions, uses checkboxes
(grid / bin, toggleable), delete button
- Purpose: correct mislabeled source images
---
## Widget Patterns
These are the recurring structural widgets across the app. Listing all instances
makes the taxonomy visible and suggests what a higher-level UI DSL would need to
express.
---
### Split pane (master/detail)
A resizable two-column layout. Left = filterable list. Right = detail view of
selected item, or a placeholder.
| Instance | Left | Right |
|----------|------|-------|
| Components | Component list + search | Detail panel (fields, images, files, inventory) |
Currently only one instance. The pattern is general enough to reuse for grids,
bins, etc.
---
### Tabbed section
A tab bar that switches between content panels within the same section. Tab state
can be reflected in the URL (`/bins/sources`, `/grids/sources`).
| Instance | Tabs |
|----------|------|
| Grids section | Grids \| Source images |
| Bins section | Bins \| Source images \| Types |
| Bin editor dialog | Fields \| Contents |
---
### Table view
Toolbar (search/filter + action button) + `<table>` with rows and per-row
edit/delete icon buttons.
| Instance | Row content |
|----------|-------------|
| Inventory | Component, type badge, location ref, qty, notes |
| Fields | Name, unit, description |
---
### Card gallery
A wrapping grid of cards. Each card has a visual element (image or preview),
a title, and action buttons.
| Instance | Card type | Visual |
|----------|-----------|--------|
| Grid list | `t-grid-card` | Multi-thumbnail preview |
| Bin gallery | `t-bin-card` | Processed image or placeholder |
| Source image gallery | `t-source-card` | Photo thumbnail |
| Template list | `t-template-card` | Formatter code `<pre>` |
---
### List row with actions
A horizontal row with info spans on the left and icon buttons on the right. Used
in data-table rows and standalone list elements.
| Instance | Info shown |
|----------|------------|
| Inventory table rows | Component, type, location, qty, notes |
| Field table rows | Name, unit, description |
| Bin type rows | Name, dimensions, description |
| Bin content rows | Name/component, qty, notes |
| Image admin rows | Thumbnail, filename, dimensions, uses checkboxes |
---
### Field editor (dynamic rows)
A list of label + text input + remove button rows, plus an "add field" dropdown
and "New…" button. Shared across component edit, bin edit, and bin type edit via
`build_field_editor()`.
| Instance | Host |
|----------|------|
| Component edit dialog | `#c-field-rows` |
| Bin editor — Fields tab | `#bin-field-rows` |
| Bin type dialog | `#bt-field-rows` |
---
### Modal dialog
`<dialog>` element shown with `.showModal()`. All injected at init from
`<template>` elements. Closed via Cancel button, form submit, or backdrop click.
Save callback stored as a module-level variable, called by the registered submit
handler.
| Dialog | Purpose | Key inputs |
|--------|---------|------------|
| `dialog-component` | Create/edit component | Name, description, field editor |
| `dialog-inventory` | Create/edit inventory entry | Component, type, location, qty, notes, grid picker |
| `dialog-field` | Create/edit field definition | Name, unit, description |
| `dialog-template` | Create/edit name formatter | Name, JS code, test data, live preview |
| `dialog-new-grid` | Grid creation wizard | Name, rows, cols, photo coverage |
| `dialog-source-picker` | Pick a source image | Upload or select from gallery |
| `dialog-confirm` | Generic confirm/delete | Dynamic message text |
| `dialog-file-picker` | Link a PDF to a component | PDF list, upload section |
| `dialog-bin-editor` | Edit bin (image, corners, fields, contents) | Name, type, image/canvas, tabs |
| `dialog-bin-type` | Create/edit bin type | Name, dimensions, description, field editor |
| `dialog-bin-content` | Add/edit bin content item | Type (component/free text), qty, notes |
---
### Non-modal overlay / panel
Injected into the DOM and shown/hidden with `hidden`. Not a `<dialog>` — does not
block the rest of the UI.
| Instance | Trigger | Content |
|----------|---------|---------|
| Cell inventory overlay | Click grid cell in viewer | Inventory entries for that cell, + add button |
---
### Full-page sub-view (section replacement)
Replaces the entire `<main>` content. Not a dialog or overlay — the user is
"inside" a different mode of the same section. Back navigation returns to the
section list view.
| Instance | Entered from | Content |
|----------|-------------|---------|
| Grid setup | New grid wizard → source picker | Canvas corner editor + controls |
| Panel manager | Grid card or setup → next | Editable panel slot grid |
| Grid viewer | Grid card click | Cell thumbnail grid, cell inventory overlay |
---
### Canvas editor
`Grid_Setup` class renders into a `<canvas>`. Handles pan (middle mouse), zoom
(wheel), and corner/edge handle dragging for perspective correction. Used in two
places with identical behaviour.
| Instance | Embedded in |
|----------|-------------|
| Grid setup sub-view | `t-grid-setup` canvas |
| Bin corner editor | `dialog-bin-editor` (revealed on "Adjust corners") |
---
### Image lightbox
Full-screen image overlay triggered by clicking thumbnails. Single global
instance, not per-section. Clicking anywhere closes it.
---
## Complete Template Inventory
| Template ID | Type | Renders |
|-------------|------|---------|
| `t-section-components` | Section | Split-pane container |
| `t-section-inventory` | Section | Table container |
| `t-section-fields` | Section | Table container |
| `t-section-templates` | Section | Card list container |
| `t-section-grids` | Section | Tabbed container |
| `t-section-bins` | Section | Tabbed container |
| `t-section-images` | Section | Admin list container |
| `t-component-row` | List item | Name + field-value badge tags |
| `t-field-tag` | Badge | Single field name + value |
| `t-detail-placeholder` | Placeholder | "Select a component" message |
| `t-detail-content` | Detail panel | Full component detail |
| `t-detail-field-row` | Row | Field name + rendered value |
| `t-detail-inv-entry` | Row | Inventory entry with images sub-gallery |
| `t-image-thumb` | Thumbnail | Image link + delete button |
| `t-inventory-row` | Table row | Inventory entry (all columns) |
| `t-field-row` | Table row | Field definition (all columns) |
| `t-empty-row` | Table empty | colspan message cell |
| `t-empty-block` | Block empty | Div with message |
| `t-template-card` | Card | Template name + code + actions |
| `t-source-card` | Card | Source image + meta + uses badges |
| `t-draft-card` | Card | Draft grid + badge + actions |
| `t-grid-card` | Card | Grid preview thumbs + meta + actions |
| `t-panel-manager` | Sub-view | Panel slot editor grid |
| `t-panel-slot` | Grid cell | Slot thumbnail + label |
| `t-grid-setup` | Sub-view | Canvas + controls |
| `t-grid-viewer` | Sub-view | Cell thumbnail grid |
| `t-grid-cell` | Grid cell | Cell image + location label |
| `t-bin-card` | Card | Bin image or placeholder + actions |
| `t-bin-type-row` | Row | Bin type info + actions |
| `t-bin-content-row` | Row | Content item info + actions |
| `t-img-admin-row` | Row | Source image admin row |
| `t-dialog-component` | Dialog | Component create/edit |
| `t-dialog-inventory` | Dialog | Inventory entry create/edit |
| `t-dialog-field` | Dialog | Field definition create/edit |
| `t-dialog-template` | Dialog | Template create/edit + preview |
| `t-dialog-new-grid` | Dialog | Grid creation wizard |
| `t-dialog-source-picker` | Dialog | Source image selector |
| `t-dialog-confirm` | Dialog | Generic confirm |
| `t-dialog-file-picker` | Dialog | PDF picker + upload |
| `t-dialog-bin-editor` | Dialog | Bin edit (image, corners, fields, contents) |
| `t-dialog-bin-type` | Dialog | Bin type create/edit |
| `t-dialog-bin-content` | Dialog | Bin content item create/edit |
| `t-cell-inventory` | Overlay | Grid cell inventory entries |
---
## Notes for a Higher-Level UI Representation
The widgets above map fairly cleanly onto a small set of primitives that a UI DSL
would need:
- **Section** — a top-level navigable view, registered in the nav bar
- **Tabs** — switch between named content panels, state optionally in URL
- **SplitPane** — resizable master/detail, left = list, right = detail
- **Table** — toolbar + rows, each row has a schema and action set
- **Gallery** — wrapping grid of cards, each card has a schema
- **Row** — horizontal item with info fields and icon actions
- **FieldEditor** — dynamic key/value input list (add/remove/edit)
- **Dialog** — modal form with a save callback, injected from a template
- **Overlay** — non-modal panel, shown/hidden in place
- **SubView** — full-page mode that replaces main content
- **Canvas** — bespoke interactive widget (not expressible declaratively)
- **Lightbox** — global full-screen image viewer
Most sections are composed of 23 of these primitives. The Components section
(SplitPane → Table + Gallery + FieldEditor) is the most complex. The canvas-based
views are the only things that require imperative escape hatches.