Compare commits

...

99 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
80a2fabf7d future-plans: type field approach for item types, bulk migration strategy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 03:38:02 +00:00
e83d3978b0 future-plans: bins as items, inventory type-specific views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 03:36:51 +00:00
34dc1d441c future-plans: search should also match on field names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:42:03 +00:00
6874b9482a future-plans: multi-user, team permissions, shared auth library
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 21:10:16 +00:00
55f8766176 future-plans: image gallery with drag-drop, paste, URL, and shared images
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 20:47:11 +00:00
2405be6a66 future-plans: semantically-aware formatting for acronyms and proper names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:00:03 +00:00
5ac980c9fa future-plans: unified formatter→renderer pipeline and terminology revision
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:53:53 +00:00
a6bd340d81 future-plans: rich return values from templates (slots, not just strings)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:51:56 +00:00
eeb77babbb future-plans: use CodeMirror 6 for JavaScript input fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:25:55 +00:00
5681d5f024 future-plans: migrate to integer IDs via explicit safe migration tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:06:24 +00:00
210fb1e037 future-plans: component IDs in dropdowns, user-assignable short IDs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:04:57 +00:00
110e17e972 future-plans: sort component list by display name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:03:53 +00:00
84dc06f365 future-plans: custom field input modes (e.g. SMD codes vs direct entry)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:55:34 +00:00
7265b5bb2c future-plans: recent locations, field keyboard shortcut, explicit save button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:50:44 +00:00
27bf6043d3 future-plans: clarify logical cell grouping is for batch overflow, not large components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 00:24:59 +00:00
1bebf7a12b future-plans: irregular grid layouts, merged cells, stacked sub-grids
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 00:22:47 +00:00
85170d4b50 future-plans: read-only public mode via runtime flag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:19:00 +00:00
94b20dda6b future-plans: grid view layers with separate source images
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:38:41 +00:00
88cc71b7d3 future-plans: custom saved search views with JS expressions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:35:04 +00:00
06b2691d87 future-plans: multi-cell grid storage selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:33:35 +00:00
956f168578 future-plans: auto-select uploaded file in file picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:31:02 +00:00
07dbb6261e future-plans: store measurement as {value, prefix, unit}, canonical only for queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:05:10 +00:00
a17bafb6d3 future-plans: clarify SI prefix vs unit distinction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:03:33 +00:00
8cb1d11e40 future-plans: measurement dimensions and unit conversion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:02:32 +00:00
98190c5271 future-plans: parametric search, field filter, field grouping, DRY audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:01:08 +00:00
e7653eda83 future-plans: field detail display should use a table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:56:41 +00:00
51d1a23406 future-plans: add kv-store shared library and delta tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:35:41 +00:00
31106691d4 future-plans: add file picker search filter to short-term list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:33:13 +00:00
e61b6cd548 Remove accidental test files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 11:45:56 +00:00
4813a65a53 PDF: separate display name and filename; show filename in picker; fix rename
- Upload dialog now has distinct display name + filename fields, both pre-filled
  from the uploaded file but independently editable
- Rename in file picker shows and edits both display name and filename separately
- Filename conflict checked against both KV store and disk (via rename_no_replace)
- Display name and filename are fully independent — no longer derived from each other
- Add find_pdf_references() helper in storage.mjs for future use
- CSS: fp-name-wrap shows display name + dim monospace filename below it;
  rename mode stacks two inputs; fp-field-label for upload form labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 11:45:50 +00:00
d3df99a8f0 Fix mv-sync path; pre-fill PDF display name from filename on upload
- Fix MV_SYNC path: ../tools resolved to /workspace/tools, should be ./tools
  relative to server.mjs entry point
- Pre-populate display name input when file is selected (strips .pdf extension),
  only if the field is currently empty so manual edits are not overwritten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 11:42:35 +00:00
1fbd6403ab future-plans: add field value parser chain and long-term cache note
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 11:37:16 +00:00
13ab5867c7 Update README and future-plans to reflect current state
README: add PDF attachments, maintenance menu, mv-sync build step,
resizable pane, URL-based navigation, word-split search, grid highlights.
future-plans: add render_field_value integrations, field types, PDF paging,
inventory/grid URL state; update state variable list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:54:14 +00:00
58c93f2bd0 Many UX and correctness improvements
- Components URL reflects selected component (/components/:id), survives refresh
- Word-split search: "0603 res" matches "Resistor 0603"
- Left pane resizable with localStorage persistence
- Field values rendered via central render_field_value() (units, URLs, extensible)
- Fields sorted alphabetically in both detail view and edit dialog
- Edit component dialog widened; field rows use shared grid columns (table-like)
- No space between value and unit (supports prefix suffixes like k, M, µ)
- Grid viewer highlights and scrolls to cell when navigating from component detail
- Cell inventory overlay items are <a> tags — middle-click opens in new tab
- PDF files stored with sanitized human-readable filename, not random ID
- PDF rename also renames file on disk
- Atomic rename via renameat2(RENAME_NOREPLACE) through tools/mv-sync
- Fix .cell-thumb-link → .cell-thumb-preview (div, not anchor), cursor: zoom-in
- Fix field name overflow in detail view (auto column width, overflow-wrap)
- Fix link color: use --accent instead of browser default dark blue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:49:11 +00:00
7ef5bb5381 Render URL-like field values as clickable links in component detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:29:55 +00:00
08b8e2dd4d Replace goto-grid button with clickable grid cell label in component detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:29:08 +00:00
d489c1e306 Inventory view: click component name to navigate to component detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:27:11 +00:00
bc339bd073 Right-align grid cell index, count stays left
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:25:27 +00:00
ad96a53246 Grid cell label: left-align, count left of index
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:24:21 +00:00
258f9b6491 Fix grid cell label and count: label below image, count as green text beside it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:23:27 +00:00
e1c517c023 Grid viewer: improve cell labels and add green inventory count badge
Labels now overlay image with gradient background and visible colour.
Green badge top-right shows number of inventory entries for that cell.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:22:05 +00:00
8fa4a54f9e Widen inventory dialog; fix grid picker cell size to 64px with scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:20:17 +00:00
64af0862f2 Replace row/col number inputs with graphical grid cell picker
Click a cell in the visual grid to select it. Cell images shown where
available. Selected cell highlighted with accent border.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:16:40 +00:00
488fa7ff53 Fix inventory dialog: hidden form-row not hiding due to display:flex override
Also clarify notes label as per-location, not per-component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:13:47 +00:00
4a210047c8 Fix formatter in inventory component selector; add Duplicate button
- Inventory dialog now uses component_display_name() not c.name
- Duplicate copies name/description/fields and immediately opens edit dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:11:11 +00:00
1d3a157d75 Add lightbox for all preview images (component, grid cell, PDF thumbs)
Click any thumbnail to open full-size in overlay. Click backdrop or
press Escape to close. PDF thumbs now clickable too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:45:10 +00:00
cdefa70bd7 Match PDF thumb height to grid cell preview (128px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:42:35 +00:00
8e0f7eb4d8 Add maintenance menu (top-right ⚙) with generate missing PDF thumbnails
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:41:40 +00:00
451b04ad03 Add PDF first-page thumbnails via pdftoppm
Generated at upload time, stored alongside the PDF in data/pdfs/.
Shown in the file picker (48px) and component detail view (80px).
Gracefully skipped if pdftoppm is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:40:43 +00:00
61d52d8076 Move file linking to component detail view, consistent with images
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:24:35 +00:00
f0bedc80a7 Add PDF file attachments to components
- Upload PDFs, rename them (conflict-checked), delete them
- Link/unlink files per component (many components can share a file)
- File picker dialog: browse existing files, rename inline, upload new
- Component detail shows linked files as clickable links
- Files stored in data/pdfs/, served at /pdf/:filename
- KV prefix: pdf:

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:19:30 +00:00
e91a656dc8 Fix grid horizontal scroll: remove overflow-x:hidden from body
body was silently clipping all horizontal overflow. min-width:0 on
#main prevents flex child from expanding beyond container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:28:46 +00:00
dc0e822e9b Fix horizontal scroll in grid viewer: overflow-x on #main not body
body had overflow-x: hidden which ate the scrollbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:25:19 +00:00
b9ba6d38b5 Fix grid viewer zoom: fixed cell px size + overflow-x scroll
Cells are sized in CSS px to fit at load time. Browser zoom now scales
everything uniformly; grid scrolls horizontally instead of reflowing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:22:55 +00:00
754f8504f1 Remove grid viewer zoom slider — browser zoom is sufficient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:15:52 +00:00
faed2f8296 Fix grid viewer zoom: use CSS zoom so images and text scale together
Cells stay 1fr (always fill width), CSS zoom shrinks the whole grid
uniformly. No overflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:15:05 +00:00
878b32f9e5 Add zoom/cell-size slider to grid viewer
Images and labels scale together with a range slider (40–300px).
Grid scrolls horizontally when cells exceed viewport width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:08:25 +00:00
520728c62b Allow grid corners to be dragged outside image bounds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:02:24 +00:00
91630d35e6 README: document BIND_ADDRESS for interface selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:33:19 +00:00
d8905902e7 README: fix default port (3020, not 3000)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:32:27 +00:00
f7c4dc10b8 README: mention npm install from git HTTPS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:32:10 +00:00
590bad3374 Add README with features, install, and run instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:31:03 +00:00
38dba05ac0 Double grid cell thumbnail size to 128px 2026-03-21 00:21:32 +00:00
99299ed9f2 Show grid cell image in component detail inventory entries
Grid-type inventory entries now display the warped cell image from the
grid as a read-only thumbnail (highlighted with accent border) alongside
any user-uploaded images for that entry.

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

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

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

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

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

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

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

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/
data/
package-lock.json
tools/mv-sync

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

166
README.md Normal file
View File

@@ -0,0 +1,166 @@
# Electronics Inventory
A self-hosted web app for managing an electronics component collection. Track components, their
field values, physical storage locations, and visual panel/grid layouts.
## Features
### Components
Define reusable component types (e.g. Resistor, Capacitor, IC) with custom fields per component.
Components show up in inventory entries and can be navigated to directly from storage locations.
- Word-split search: searching `0603 res` matches `Resistor 0603`
- Selected component reflected in URL (`/components/:id`) — survives page refresh
- Resizable list pane (width persisted in localStorage)
- Duplicate button to quickly clone a component
- Field values sorted alphabetically, rendered centrally (units, URLs as links, extensible)
### Fields
Create custom field definitions (e.g. `resistance`, `capacitance`, `package`) with an optional
unit suffix. Unit is appended directly to the value with no space, so you can write `4k7` for a
resistance field with unit `Ω` and it displays as `4k7Ω`.
URL-like field values (beginning with `http://` or `https://`) are automatically rendered as
clickable links.
### Inventory
Log where things are stored. Each inventory entry links a component to a storage location.
Supported location types:
- **Grid cell** — a specific row/column in a named grid (e.g. drawer divider box), picked
visually with a graphical cell picker
- *(plain entries without a grid reference work too)*
Notes on inventory entries are per storage location (not per component).
### Grids
Model physical storage grids (drawer organizers, parts boxes, etc.).
1. Upload a photo of the grid
2. Set up corner points to map the image to a logical grid (corners can extend outside image bounds)
3. Define row/column counts
4. Click any cell to see what's stored there — component entries are links, middle-click opens in new tab
5. Each cell shows a green count badge of how many components reference it
6. Navigating to a grid from a component detail highlights and scrolls to the relevant cell
### PDF Attachments
Attach PDF datasheets or other documents to components.
- PDFs are stored with a sanitized human-readable filename derived from the display name
- Rename a PDF and the file on disk is also renamed, atomically (uses `renameat2 RENAME_NOREPLACE`
via `tools/mv-sync`)
- First-page thumbnails generated automatically via `pdftoppm` (poppler-utils) if available
- Multiple components can share the same PDF
- Click any thumbnail to open it full-size in a lightbox
### Templates (Name Formatters)
Write JavaScript formatter functions that generate smart display names for components based on their
field values. Example:
```js
(c) => {
const F = c.fields;
if (F.resistance && F.imperial_package) {
return `${F.resistance}Ω ${F.imperial_package}`;
}
}
```
Fields are accessed by name (e.g. `c.fields.resistance`). If the formatter returns a non-empty
string it's used as the display name; otherwise the component's base name is used as fallback.
Multiple formatters are tried in order.
The template editor includes a test data box so you can preview the output without needing real
inventory data.
### Maintenance
A ⚙ menu in the top-right corner provides maintenance operations:
- **Generate missing PDF thumbnails** — scans all PDFs and generates thumbnails for any that
don't have one yet (useful if `pdftoppm` was unavailable at upload time)
## Requirements
- Node.js >= 25
- npm
- `gcc` (to build `tools/mv-sync` — only needed once)
- `pdftoppm` from poppler-utils (optional, for PDF thumbnails)
## Install
Install directly from the git repository — there is no npm package yet:
```bash
npm install git+https://gitea.efforting.tech/mikael-lovqvists-claude-agent/electronics-inventory.git
```
Or clone if you prefer to keep the source around:
```bash
git clone https://gitea.efforting.tech/mikael-lovqvists-claude-agent/electronics-inventory
cd electronics-inventory
npm install
```
## Build native tools
```bash
cd tools && make
```
This compiles `mv-sync`, a small helper that performs an atomic rename-without-overwrite using
`renameat2(RENAME_NOREPLACE)` (Linux 3.15+). It is required for PDF file operations.
## Run
```bash
npm start
```
The server starts on port 3020, bound to `localhost` only, by default.
Open [http://localhost:3020](http://localhost:3020) in your browser.
Both can be overridden with environment variables:
```bash
PORT=8080 npm start # different port
BIND_ADDRESS=0.0.0.0 npm start # all interfaces (LAN accessible)
BIND_ADDRESS=192.168.1.50 npm start # specific interface
PORT=8080 BIND_ADDRESS=0.0.0.0 npm start # both
```
> **Note:** The default `localhost` binding means the app is only reachable from the same machine.
> Set `BIND_ADDRESS=0.0.0.0` to expose it on your local network.
## Data Storage
All data is stored locally in a `data/` directory created automatically on first run:
- `data/db.json` — component, field, inventory, grid, template, and PDF records (flat KV store)
- `data/images/` — uploaded source images and component/inventory photos
- `data/pdfs/` — uploaded PDF files and their thumbnails
No external database is required.
## Project Structure
```
server.mjs Express 5 API server + SPA host
lib/
storage.mjs Server-side KV store wrappers
kv-store.mjs JSON file-backed key-value store
ids.mjs ID generation
grid-image.mjs Grid image processing helpers
tools/
mv-sync.c Atomic rename helper (renameat2 RENAME_NOREPLACE)
Makefile
public/
app.mjs Single-page app (vanilla JS ES modules)
templates.html HTML templates (lazy-loaded)
style.css Styles
lib/
api.mjs Fetch wrappers for the REST API
dom.mjs DOM helpers
views/
grid-setup.mjs Canvas-based grid corner editor
```

561
future-plans.md Normal file
View File

@@ -0,0 +1,561 @@
# Future Plans
## KV store
### Extract into shared library
`kv-store.mjs` is already copied into at least 3 projects (`electronics-inventory`,
`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.
### 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
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
clean current-state snapshot; the delta file accumulates the full history. Enables
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
### parse_url mutates too many module-level variables
`parse_url()` directly assigns to a large number of module-level state variables
(`section`, `grid_view_state`, `grid_tab`, `current_grid_id`, `grid_draft`,
`current_panel_idx`, `grid_source_id`, `highlight_cell`, `selected_component_id`).
This is fragile and hard to reason about.
Preferred direction: represent the full UI state as a single immutable state object,
and have `parse_url()` return a new state value rather than mutating globals:
```js
function parse_url(path) {
return { section, grid_view_state, current_grid_id, ... };
}
state = parse_url(location.pathname); render(state);
```
### render() if/else chain
The render dispatcher is a long chain of bare `else if` branches. Replace with a
lookup table:
```js
const SECTION_RENDERERS = {
components: render_components,
inventory: render_inventory,
fields: render_fields,
grids: render_grids,
templates: render_templates,
};
function render() { sync_nav(); SECTION_RENDERERS[section]?.(); }
```
### 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` is large. Split into per-section view modules (`views/components.mjs`,
`views/grids.mjs`, `views/bins.mjs`, etc.) each owning its local state, subscribing
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
Currently any change in the component detail panel (linking a file, unlinking an
inventory entry, etc.) is persisted immediately. This makes it hard to experiment or
undo. The component editor dialog should have an explicit Save button and hold all
changes locally until confirmed. Relates to the broader question of whether live
mutations elsewhere in the UI should be deferred similarly.
### DRY / SSoT audit
As the app grows, patterns are being duplicated rather than centralized. Areas to
review:
- Field sorting: same sort-by-name logic appears in both detail view and edit dialog
- Field rendering: `render_field_value()` exists but call sites still sometimes
inline display logic
- Component display name: `component_display_name()` is the SSoT but there may be
call sites that still use `c.name` directly
- Server-side: PDF conflict checks, sanitize calls, and rename logic are inline in
route handlers — could be extracted into a `pdf_service` helper
- General pass to identify and eliminate copy-paste between routes and between
render functions before the codebase grows further
## Field system
### Improvements
#### Component IDs in dropdowns and lists
The component selector dropdown (e.g. in the inventory entry dialog) only shows the
display name, which is ambiguous when multiple components share a name. Should also
show the component ID.
#### Migrate to integer IDs
Current IDs are timestamp-base36 + random chars. Replace with plain integers
(auto-incrementing). Benefits: human-readable, shorter in URLs, sortable by creation
order, easier to reference verbally.
Migration must be done as an explicit standalone tool (`tools/migrate-ids.mjs` or
similar) that:
1. Reads the current database
2. Builds an old→new ID mapping for all entity types (components, fields, inventory
entries, grids, PDFs, etc.)
3. Rewrites all references throughout the data (e.g. inventory entries reference
component IDs, components reference field IDs, file_ids arrays, etc.)
4. Writes a new database file without touching the original until explicitly
confirmed
5. Keeps a mapping log so the migration is auditable and reversible
Should not be run automatically — operator invokes it deliberately after backing up.
#### Component list sorted by display name
The component left pane list is currently sorted by base name. It should sort by
display name (i.e. the formatter output) so the list order matches what the user
actually sees as the component label.
#### Field display in component detail should use a table
Currently rendered as CSS grid rows but columns don't align because each row is
independent. Use an actual `<table>` so name and value columns line up across all
fields. This is tabular data and a table is the right element.
#### Field value parser chain
Similar to how name formatters use a template chain, field values could be passed
through a parser chain that returns structured data based on field name/type hints.
Examples:
- A field whose name contains `tolerance` could parse `20` as `{ negative: 20, positive: 20 }`
and `-10/+30` as `{ negative: 10, positive: 30 }`
- URL detection (currently hardcoded in `render_field_value()`) could be one parser
in this chain rather than a special case
- Mouser/Digi-Key part numbers could be detected and return a structured link target
The parser chain would mirror the template system: user-defined or built-in parsers
keyed by field name pattern, tried in order, returning structured data or `null` to
pass through to the next. `render_field_value()` would then receive parsed data and
render accordingly.
#### Field rendering integrations
With or without a parser chain, `render_field_value()` should gain:
- Mouser/Digi-Key part number fields → auto-craft links to product pages
- More URL-like patterns (without `https://` prefix)
#### Field selector filter
When adding a field to a component in the edit dialog, the dropdown becomes
unwieldy with many fields. Add a filter/search input to the field selector.
#### Custom field input modes
Fields could support multiple named input modes that accept different notations and
convert to the canonical stored value. Example for resistance:
- `direct` — enter `100k` or `4k7` directly
- `3-digit SMD` — enter `104` (decoded as 10×10⁴ = 100kΩ)
- `4-digit SMD` — enter `1003` (decoded as 100×10³ = 100kΩ)
The active input mode is selected via a small dropdown next to the field input, with
a keyboard shortcut to cycle through modes quickly. The last used mode per field is
remembered. Input modes are associated with the field definition (or the measurement
dimension), not per-component. Ties in with the measurement/dimension system —
modes are really just different parsers that produce the same canonical value.
#### Keyboard shortcut for adding a field
When filling out many fields on a component, repeatedly reaching for the mouse to
hit "add field" is slow. Add a configurable keyboard shortcut (e.g. Alt+F) to
focus/trigger the add-field selector from anywhere in the component editor.
#### Search matches field names
The current word-split search only matches field values, not field names. Should
also match on field names so searching `dielectric_characteristics` finds all
components that have that field set, regardless of its value.
#### Parametric search
Allow searching/filtering components by field values, not just names. Examples:
- `resistance < 10k`, `package = 0603`, `voltage_rating >= 50`
- Cross-field queries: find all 0603 resistors under 10kΩ
- Should integrate with the existing word-split search or replace it with a
richer query language
- Depends on field types (numeric vs string) for range queries to work correctly
### Long term
#### Field grouping / linkage
Some fields naturally belong together (e.g. `frequency_stability` and
`frequency_stability_temp_range`). Options:
- Soft linkage: tag fields with a group name, display grouped in the UI
- Structured fields: a field can be a record type with named sub-fields
(e.g. `stability: { value: 10, unit: "ppm", temp_low: -40, temp_high: 85 }`)
Structured records are the more powerful option but require a schema system and
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)
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
values carry no semantic metadata about what kind of string they are. A long-term
fix requires fields to be semantically rich enough that formatters know whether a
value is an acronym, brand name, common noun, number, etc., and apply appropriate
rules per token. Relates to field types and structured field value work.
#### Renderer/parser result cache
Once parsers and formatters run per-render, a cache keyed on field value + template
version would avoid redundant work on large inventories. Invalidated when any
template changes. Not urgent — premature until the parser chain exists.
#### Field types
Currently all field values are free-text strings. Typed fields (numeric,
enum/dropdown) would enable better formatting, validation, and range-aware search.
Prerequisite for parametric search with range operators.
#### Measurement dimensions and unit conversion
Instead of a bare unit string on a field, associate a field with a measurement
dimension (e.g. `temperature`, `resistance`, `frequency`, `voltage`). The dimension
defines the set of valid units and the conversion factors between them (°C, °K, °R,
°F for temperature; Ω, kΩ, MΩ for resistance; etc.).
SI prefixes (k, M, µ, n, p, etc.) are not separate units — they are a presentation
layer on top of a unit. `25kΩ` should be stored as
`{ value: "25", prefix: "k", unit: "Ω" }` — preserving the original string value
and prefix exactly as entered, so no precision or notation is lost.
A canonical numeric form is derived from the stored triple only when needed for
comparison or search queries (e.g. `R < 10k` → compare canonical floats). Display
always reconstructs from the stored `value + prefix + unit`, so `4k7` stays `4k7`
and `25.0` stays `25.0`.
This would allow:
- Lossless storage of entered values (significant digits, notation style preserved)
- Parametric search with cross-prefix comparisons via derived canonical values
- Unit conversion on query (e.g. `temp > 200K` matching a stored `-73°C`)
- Catching unit mismatches at entry time
## Multi-user and access control
### Multi-user support
Currently single-user with no authentication. For shared/team use:
- User accounts with login (session or token-based)
- Per-user audit trail (who added/changed what, ties into delta tracking)
- Optional: user-specific preferences (display units, default grid, etc.)
### Team / permission model
Teams or roles controlling what users can do:
- Read-only members (view inventory, no edits)
- Contributors (add/edit components and inventory)
- Admins (manage fields, grids, users)
- Possible per-resource permissions (e.g. a team owns a specific grid)
### Common user/team library
User and team management is a recurring need across projects. Should be extracted
into a shared library (alongside the planned kv-store library) rather than
reimplemented per project. The library would provide:
- User CRUD with hashed credentials
- Session/token management
- Role and permission primitives
- Middleware for Express (protect routes by role)
The electronics inventory would then depend on this library rather than rolling its
own auth. Other projects (`publication-tool`, future apps) would do the same.
## Deployment
### Read-only public mode
A runtime flag (e.g. `READ_ONLY=1`) that starts the server in a read-only mode
suitable for public-facing deployment:
- All write API endpoints disabled (POST/PUT/DELETE return 403)
- UI hides all edit controls, dialogs, and maintenance actions
- Data served directly from the same `data/` directory
This allows a simple deployment workflow: rsync the `data/` directory from the
private instance to a public server running in read-only mode. No database sync,
no separate export step.
## Editor
### Use CodeMirror 6 for JavaScript input fields
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.
## Template system
### Unified formatter → template pipeline and terminology revision
The current system conflates several distinct concepts under the word "template",
creating ambiguity:
- The HTML `<template>` elements used for UI cloning (internal, not user-facing)
- The user-written JS formatter functions (currently called "templates" in the UI)
- The future idea of user-defined DOM rendering templates
Proposed clearer terminology:
- **Formatter** — a user-written JS function that receives a component and returns a
structured record (named slots), e.g. `{ label, sublabel, badge, ... }`
- **Renderer** — a DOM fragment template (possibly user-defined) that consumes a
formatter's record and produces the visual output for a given context (list row,
detail header, dropdown item, etc.)
- **View template** — the internal HTML `<template>` cloning mechanism (keep as-is,
but don't expose this term to users)
The pipeline becomes: `component → formatter → record → renderer → DOM`. Formatters
and renderers are decoupled — the same formatter record can feed different renderers
in different contexts. Users can define custom renderers (DOM fragments with named
slot targets) in addition to custom formatters.
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 →
context-specific renderer.
## Search & views
### Custom search views
Saved searches defined as JS expressions (similar to the template system), evaluated
against each component to produce a filtered and optionally transformed list. Example
use cases:
- "all components with a non-empty `todo` field"
- "all SMD resistors with no datasheet attached"
- "all components missing a `package` field"
Views would be named, saved, and accessible from the nav or a dedicated views
section. The expression receives the full component object and returns truthy to
include it. Could later be extended to also control sort order and displayed columns.
## Images
### Image gallery / browser
The current image upload for components is minimal. Replace with a proper image
gallery dialog (mirroring the PDF file picker) that shows all uploaded images with
thumbnails and supports:
- File input upload (existing)
- Drag and drop onto the gallery area
- Clipboard paste (Ctrl+V — useful for pasting screenshots directly)
- URL entry (fetch and store server-side)
Images should be manageable from the gallery: rename, delete, link/unlink from
component, open full-size in lightbox. Like the PDF picker, the gallery should be
reusable across components (an image can be shared between components).
## PDF / files
### Auto-select file after upload in file picker
When uploading a PDF from within the file picker (opened from a component), the
newly uploaded file should be automatically linked to that component without
requiring a manual "Select" click.
### File picker search filter
The file picker dialog has no search/filter input. With many datasheets this becomes
unwieldy. Add a filter input at the top of the list that narrows by display name and
filename.
### PDF page count and multi-page navigation
Currently only the first page thumbnail is shown. Could show page count and allow
browsing pages in the lightbox.
## Inventory
### Bins as a storage item type
Support bins (physical containers, boxes, bags, reels, etc.) as inventory items in
their own right — not just as locations. A bin can hold components but is itself a
trackable thing. Bins may contain non-electronic items.
### Inventory type-specific views
Currently the inventory and components views are tightly coupled to the assumption
that everything is an electronic component. Long term, the system should support
multiple item types (components, bins, tools, materials, etc.) with:
- A generic "everything" view showing all inventory regardless of type
- Type-specific views (e.g. the current components view) that filter and present
items with type-relevant fields and UI
- The current components section becomes one such type-specific view rather than
the only view
**Implementation approach:** Add a `type` field to items (e.g. `component`, `bin`,
`tool`). Type-specific views are just filtered views over all items. No separate
collection or schema per type — the type field drives which view renders it.
**Migration:** Bulk assignment via the existing field system — e.g. set `type =
"component"` on all current items in one operation, since they're all components.
No per-item manual work needed.
### Inventory URL reflects selected entry
Similar to how components now reflect `/components/:id` in the URL, inventory
entries have no URL state — refreshing loses context.
### Recent locations in inventory entry dialog
When picking a storage location for a component, show a list of recently
used/visited locations at the top so you can quickly re-select where you just were.
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.
## 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
### Grid view layers
Allow a storage grid to reference one or more separate "view layer" grids that share
the same logical layout but use different source images. Example: the storage grid
uses close-up photos of individual cells for identification, while a view layer uses
a wider photo of the lid or top side for orientation.
Key design points:
- Grids get a classification: `storage` (can hold inventory) or `view` (display
only, referenced by storage grids)
- View layers may use a different panel sub-grid layout (fewer, larger panels) as
long as the final logical row×col count matches the storage grid
- In the grid viewer, layers can be toggled to switch between the storage view and
any attached view layers
- A storage grid can have multiple view layers (e.g. lid photo, tray photo, labeled
overlay)
### Irregular grid layouts and merged cells
Real storage boxes rarely have perfectly uniform grids. Two distinct physical
configurations need to be supported:
**Type A — uniform grid with merged cells:** A regular N×M grid where some adjacent
cells are physically merged into one larger cell (always an integer multiple of the
base cell size). Common in component assortment boxes. A merged cell is both a
physical and logical unit — you store one thing in it.
**Type B — stacked sub-grids:** A container where each row (or section) has a
different column count and cell size. Example: 5 rows of 5 small columns, then 1
row of 4 medium columns, then 1 row with a single large drawer. Cells are not
multiples of a common base — the sections are structurally independent.
**Logical merging (cell groups):** Independent of physical layout, a user should be
able to group several cells into a single named logical location. The motivating
case is a batch of 50 components that won't fit in one cell — they spill across 3
cells, but you want one inventory entry saying "these cells together hold this
batch", not three separate entries to keep in sync. This is purely a
storage/inventory concern, not a grid layout concern.
**Open question — architecture:** Should this be:
1. A single generic nested/hierarchical grid model flexible enough to encode both
types (more complex but unified), or
2. Two explicit grid styles (`uniform+merges` and `stacked-sections`) that cover
the common cases without a fully general solution?
Option 2 is likely sufficient for real-world boxes and much easier to implement and
display. Worth prototyping before committing to a generic model.
### Multi-cell grid storage selection
A component stored in a grid should be able to span multiple cells, since larger
parts often occupy more than one cell. The graphical cell picker in the inventory
dialog should support selecting a range or set of cells rather than a single cell.
The grid viewer should reflect multi-cell occupancy in its count badges and cell
highlighting.
### Grid URL state
Navigating into a grid viewer updates the URL correctly, but the grid list and
draft state have no URL representation.

View File

@@ -86,6 +86,22 @@ function bilinear_sample(pixels, width, height, x, y, out, out_idx) {
// 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
export function compute_cell_size(corners, rows, cols) {
const [tl, tr, br, bl] = corners;

View File

@@ -151,12 +151,14 @@ export class Simple_KeyValue_Store {
load() {
const { data, storage_path } = this;
if (!fs.existsSync(storage_path)) {
return;
let file_contents;
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')) {
if (!line) continue;
const [key, value] = JSON.parse(line);

View File

@@ -125,6 +125,99 @@ export function delete_source_image(id) {
return store.delete(`s:${id}`);
}
// --- Component templates ---
export function list_component_templates() {
const result = [];
for (const [key] of store.data.entries()) {
if (key.startsWith('ct:')) result.push(store.get(key));
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
export function get_component_template(id) {
return store.get(`ct:${id}`) ?? null;
}
export function set_component_template(tmpl) {
store.set(`ct:${tmpl.id}`, tmpl);
}
export function delete_component_template(id) {
return store.delete(`ct:${id}`);
}
// --- PDF files ---
export function list_pdfs() {
const result = [];
for (const [key] of store.data.entries()) {
if (key.startsWith('pdf:')) result.push(store.get(key));
}
return result.sort((a, b) => a.display_name.localeCompare(b.display_name));
}
export function get_pdf(id) {
return store.get(`pdf:${id}`) ?? null;
}
export function set_pdf(pdf) {
store.set(`pdf:${pdf.id}`, pdf);
}
export function delete_pdf(id) {
return store.delete(`pdf:${id}`);
}
// Returns all components that reference the given PDF id in their file_ids array.
export function find_pdf_references(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 ---
export function list_grid_images() {

829
package-lock.json generated
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,22 @@
<button class="nav-btn" data-section="inventory">Inventory</button>
<button class="nav-btn" data-section="fields">Fields</button>
<button class="nav-btn" data-section="grids">Grids</button>
<button class="nav-btn" data-section="templates">Templates</button>
<button class="nav-btn" data-section="bins">Bins</button>
<button class="nav-btn" data-section="images">Images</button>
</nav>
<div class="maint-menu" id="maint-menu">
<button class="maint-toggle" id="maint-toggle" title="Maintenance"></button>
<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-purge-sources">Remove orphaned source image entries</button>
</div>
</div>
</header>
<main id="main"></main>
<div id="lightbox" hidden>
<img id="lightbox-img" alt="">
</div>
<script type="module" src="/app.mjs"></script>
</body>
</html>

View File

@@ -35,8 +35,62 @@ 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}`);
// 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 get_source_images = () => req('GET', '/api/source-images');
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
export const get_component_templates = () => req('GET', '/api/component-templates');
export const create_component_template = (body) => req('POST', '/api/component-templates', body);
export const update_component_template = (id, body) => req('PUT', `/api/component-templates/${id}`, body);
export const delete_component_template = (id) => req('DELETE', `/api/component-templates/${id}`);
// PDF files
export const get_pdfs = () => req('GET', '/api/pdfs');
export const rename_pdf = (id, display_name, filename) => req('PUT', `/api/pdfs/${id}`, { display_name, filename });
export const delete_pdf = (id) => req('DELETE', `/api/pdfs/${id}`);
export async function upload_pdf(file, display_name, filename) {
const form = new FormData();
form.append('file', file);
if (display_name) form.append('display_name', display_name);
if (filename) form.append('filename', filename);
const res = await fetch('/api/pdfs', { method: 'POST', body: form });
const data = await res.json();
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
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
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
export const get_grids = () => req('GET', '/api/grid-images');

View File

@@ -32,6 +32,14 @@
--font-mono: 'Fira Code', 'Cascadia Code', monospace;
}
a {
color: var(--accent);
}
a:hover {
color: var(--accent-hover);
}
body {
background: var(--bg);
color: var(--text);
@@ -41,7 +49,6 @@ body {
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* ===== HEADER ===== */
@@ -93,22 +100,116 @@ nav {
background: rgba(91, 156, 246, 0.12);
}
/* ===== MAINTENANCE MENU ===== */
.maint-menu {
margin-left: auto;
position: relative;
}
.maint-toggle {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.2rem;
cursor: pointer;
padding: 0.3rem 0.5rem;
border-radius: 4px;
line-height: 1;
transition: color 0.1s, background 0.1s;
}
.maint-toggle:hover {
color: var(--text);
background: var(--surface-raised);
}
.maint-dropdown {
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
min-width: 240px;
z-index: 200;
padding: 0.3rem;
}
.maint-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
color: var(--text);
font-family: inherit;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.maint-item:hover {
background: var(--surface-raised);
}
/* ===== LIGHTBOX ===== */
#lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: zoom-out;
}
#lightbox[hidden] {
display: none;
}
#lightbox-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 3px;
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
}
/* ===== MAIN ===== */
#main {
flex: 1;
padding: 1.5rem;
width: 100%;
min-width: 0;
}
/* ===== SPLIT LAYOUT ===== */
.split-layout {
display: flex;
gap: 1.25rem;
align-items: flex-start;
}
.split-resizer {
width: 5px;
flex-shrink: 0;
align-self: stretch;
cursor: col-resize;
background: transparent;
transition: background 0.15s;
}
.split-resizer:hover,
.split-resizer.dragging {
background: var(--accent);
}
.list-pane {
width: 300px;
flex-shrink: 0;
@@ -119,6 +220,7 @@ nav {
top: calc(3rem + 1.5rem); /* header + main padding */
max-height: calc(100vh - 3rem - 3rem);
overflow-y: auto;
margin-right: 0.625rem;
}
.quick-add-input {
@@ -156,6 +258,7 @@ nav {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
margin-left: 0.625rem;
}
/* ===== DETAIL PANEL ===== */
@@ -220,7 +323,7 @@ nav {
.detail-field-row {
display: grid;
grid-template-columns: 10rem 1fr;
grid-template-columns: auto 1fr;
gap: 0.5rem;
font-size: 0.9rem;
padding: 0.2rem 0;
@@ -230,11 +333,16 @@ nav {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.85rem;
overflow-wrap: break-word;
min-width: 0;
max-width: 14rem;
}
.detail-field-value {
font-family: var(--font-mono);
font-size: 0.85rem;
min-width: 0;
overflow-wrap: break-word;
}
.detail-empty-note {
@@ -271,7 +379,6 @@ nav {
border-radius: 4px;
border: 1px solid var(--border);
display: block;
cursor: pointer;
transition: border-color 0.1s;
}
@@ -370,6 +477,21 @@ nav {
gap: 0.4rem;
}
.cell-thumb-preview {
display: block;
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
cursor: zoom-in;
}
.cell-thumb-preview .thumb-img {
display: block;
width: 128px;
height: 128px;
object-fit: cover;
}
/* ===== SECTION TOOLBAR ===== */
.section-toolbar {
@@ -548,6 +670,24 @@ nav {
.type-pill.type-bom { background: var(--badge-bom-bg); color: var(--badge-bom-text); }
.type-pill.type-digital { background: var(--badge-digital-bg); color: var(--badge-digital-text); }
.detail-inv-ref-link {
cursor: pointer;
color: var(--accent);
}
.detail-inv-ref-link:hover {
text-decoration: underline;
}
.inv-component-link {
cursor: pointer;
color: var(--accent);
}
.inv-component-link:hover {
text-decoration: underline;
}
.inv-quantity {
font-family: var(--font-mono);
font-size: 0.9rem;
@@ -694,6 +834,10 @@ nav {
margin-bottom: 0.9rem;
}
.form-row[hidden] {
display: none;
}
.form-row label {
font-size: 0.8rem;
text-transform: uppercase;
@@ -737,14 +881,18 @@ nav {
}
/* Field input rows inside component dialog */
.c-field-input-row {
#c-field-rows {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.5rem;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto;
gap: 0.35rem 0.6rem;
align-items: center;
margin-bottom: 0.5rem;
}
.c-field-input-row {
display: contents;
}
.c-field-input-label {
font-size: 0.85rem;
color: var(--text-dim);
@@ -752,6 +900,7 @@ nav {
display: flex;
align-items: baseline;
gap: 0.3rem;
white-space: nowrap;
}
.c-field-unit-hint {
@@ -795,6 +944,71 @@ nav {
line-height: 1.5;
}
/* ===== TEMPLATES SECTION ===== */
.template-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.template-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
}
.template-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.template-card-name {
font-weight: 600;
}
.template-card-formatter {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-dim);
white-space: pre-wrap;
margin: 0;
}
.code-input {
font-family: var(--font-mono);
font-size: 0.85rem;
width: 100%;
resize: vertical;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
}
.tmpl-preview-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
min-height: 1.5rem;
}
.tmpl-preview-value {
font-family: var(--font-mono);
color: var(--accent, #5b9cf6);
}
.section-empty-note {
color: var(--text-dim);
font-size: 0.9rem;
margin: 1rem 0;
}
/* ===== TAB BAR ===== */
.tab-bar {
@@ -871,12 +1085,49 @@ nav {
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 {
font-size: 0.75rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
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 {
@@ -1019,6 +1270,17 @@ nav {
/* ===== FORM ROW PAIR ===== */
.input-with-action {
display: flex;
gap: 0.4rem;
align-items: center;
}
.input-with-action .filter-select {
flex: 1;
min-width: 0;
}
.form-row-pair {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1246,6 +1508,8 @@ nav {
flex-shrink: 0;
}
.grid-cells {
display: grid;
gap: 4px;
@@ -1257,6 +1521,11 @@ nav {
gap: 2px;
}
.grid-cell.highlighted .grid-cell-img-wrap {
outline: 3px solid var(--accent, #5b9cf6);
outline-offset: 2px;
}
.grid-cell-img-wrap {
position: relative;
width: 100%;
@@ -1306,8 +1575,534 @@ nav {
}
.grid-cell-label {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.3em;
font-size: 0.65rem;
color: var(--text-faint);
text-align: center;
color: var(--text-dim);
font-family: var(--font-mono);
padding-left: 1px;
}
.grid-cell-count {
color: #4caf50;
font-weight: 700;
}
/* ===== GRID TYPE PILL ===== */
.type-pill.type-grid {
background: #2e7d4f;
color: #fff;
}
/* ===== GRID CELL PICKER (inventory dialog) ===== */
.i-grid-cell-picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.i-grid-visual {
display: grid;
gap: 3px;
max-height: 420px;
overflow: auto;
}
.igv-cell {
position: relative;
aspect-ratio: 1;
background: var(--surface-raised);
border: 2px solid transparent;
border-radius: 2px;
cursor: pointer;
overflow: hidden;
}
.igv-cell:hover {
border-color: var(--accent);
}
.igv-cell.igv-selected {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.igv-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
/* ===== CELL INVENTORY OVERLAY ===== */
.grid-cell {
cursor: pointer;
}
.cell-inventory-overlay {
position: fixed;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
min-width: 220px;
max-width: 320px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
z-index: 100;
}
.cell-inventory-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.cell-inventory-title {
font-weight: 600;
font-size: 0.9rem;
}
.cell-inventory-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
min-height: 1rem;
}
.cell-inv-item {
font-size: 0.85rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.cell-inv-qty {
color: var(--text-dim);
white-space: nowrap;
}
.cell-inv-item-link {
cursor: pointer;
border-radius: 3px;
padding: 0.1rem 0.25rem;
margin: 0 -0.25rem;
color: inherit;
text-decoration: none;
}
.cell-inv-item-link:hover {
background: var(--hover, rgba(255,255,255,0.06));
}
.cell-inv-empty {
font-size: 0.85rem;
color: var(--text-faint);
font-style: italic;
}
.cell-inventory-actions {
display: flex;
justify-content: flex-end;
}
.btn-sm {
padding: 0.25rem 0.6rem;
font-size: 0.82rem;
}
/* ===== FILE PICKER DIALOG ===== */
.fp-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 40vh;
overflow-y: auto;
margin-bottom: 1rem;
}
.fp-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 4px;
background: var(--surface-raised);
}
.fp-name-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
overflow: hidden;
min-width: 0;
}
.fp-name {
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fp-filename {
font-size: 0.75rem;
color: var(--text-dim);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fp-rename-input {
font-size: 0.9rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border-focus);
border-radius: 4px;
padding: 0.2rem 0.4rem;
width: 100%;
}
.fp-rename-filename {
font-family: var(--font-mono);
font-size: 0.8rem;
}
.fp-upload-section {
border-top: 1px solid var(--border);
padding-top: 1rem;
margin-top: 0.25rem;
}
.fp-upload-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.4rem;
}
.fp-field-label {
font-size: 0.85rem;
color: var(--text-dim);
white-space: nowrap;
min-width: 8rem;
}
.fp-upload-row input[type=text] {
flex: 1;
min-width: 140px;
}
/* ===== DETAIL FILE LINKS ===== */
.detail-files-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.detail-file-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pdf-thumb {
width: auto;
height: 128px;
border: 1px solid var(--border);
border-radius: 3px;
flex-shrink: 0;
}
.fp-thumb {
width: auto;
height: 48px;
border: 1px solid var(--border);
border-radius: 3px;
flex-shrink: 0;
}
.detail-file-link {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
padding: 0.2rem 0;
}
.detail-file-link:hover {
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

@@ -2,11 +2,12 @@
<template id="t-section-components">
<section class="section" id="section-components">
<div class="split-layout">
<div class="list-pane">
<div class="list-pane" id="list-pane">
<input type="search" id="component-search" class="search-input" placeholder="Search…">
<input type="text" id="quick-add" class="quick-add-input" placeholder="New component… ↵" autocomplete="off" spellcheck="false">
<div id="component-list" class="component-list"></div>
</div>
<div class="split-resizer" id="split-resizer"></div>
<div class="detail-pane" id="detail-pane"></div>
</div>
</section>
@@ -37,6 +38,7 @@
</div>
<div class="detail-header-actions">
<button class="btn btn-secondary detail-edit-btn">Edit</button>
<button class="btn btn-secondary detail-duplicate-btn">Duplicate</button>
<button class="btn btn-danger detail-delete-btn">Delete</button>
</div>
</div>
@@ -57,6 +59,14 @@
</div>
</div>
<div class="detail-block">
<div class="detail-block-label">
Files
<button class="btn btn-secondary detail-link-file-btn">+ Link file</button>
</div>
<div class="detail-files-list"></div>
</div>
<div class="detail-block">
<div class="detail-block-label">
Inventory
@@ -115,6 +125,7 @@
<option value="physical">Physical</option>
<option value="bom">BOM / Drawing</option>
<option value="digital">Digital / Note</option>
<option value="grid">Grid cell</option>
</select>
<button class="btn btn-primary" id="btn-add-inventory">+ Add entry</button>
</div>
@@ -146,6 +157,58 @@
</tr>
</template>
<!-- ===== TEMPLATES SECTION ===== -->
<template id="t-section-templates">
<section class="section" id="section-templates">
<div class="section-toolbar">
<span class="section-note">Formatters that compute display names from component fields</span>
<button class="btn btn-primary" id="btn-add-template">+ Add template</button>
</div>
<div id="template-list" class="template-list"></div>
</section>
</template>
<template id="t-template-card">
<div class="template-card">
<div class="template-card-header">
<span class="template-card-name"></span>
<span class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</div>
<pre class="template-card-formatter"></pre>
</div>
</template>
<template id="t-dialog-template">
<dialog id="dialog-template" class="app-dialog app-dialog-wide">
<h2 class="dialog-title"></h2>
<form method="dialog" id="form-template">
<div class="form-row">
<label for="tmpl-name">Name</label>
<input type="text" id="tmpl-name" required autocomplete="off" placeholder="e.g. Resistor">
</div>
<div class="form-row">
<label for="tmpl-formatter">Formatter <span class="label-hint">(JS arrow function, return null to skip)</span></label>
<textarea id="tmpl-formatter" rows="8" class="code-input" placeholder="(c) => {&#10; const r = c.fields?.resistance;&#10; if (!r) return null;&#10; return `Resistor ${r}`;&#10;}"></textarea>
</div>
<div class="form-row">
<label for="tmpl-test-data">Test data <span class="label-hint">(optional — return a fields object to preview against)</span></label>
<textarea id="tmpl-test-data" rows="3" class="code-input" placeholder="return { resistance: '10k', mounting_tech: 'PTH' }"></textarea>
</div>
<div class="tmpl-preview-row">
<span class="label-hint">Preview:</span>
<span id="tmpl-preview" class="tmpl-preview-value"></span>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="tmpl-cancel">Cancel</button>
<button type="submit" class="btn btn-primary" id="tmpl-save">Save</button>
</div>
</form>
</dialog>
</template>
<!-- ===== FIELDS SECTION ===== -->
<template id="t-section-fields">
<section class="section" id="section-fields">
@@ -185,6 +248,27 @@
<div class="empty-state"></div>
</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 ===== -->
<template id="t-section-grids">
<section class="section" id="section-grids">
@@ -213,7 +297,11 @@
<a class="source-card-link" target="_blank" rel="noopener">
<img class="source-card-img" alt="">
</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>
</div>
</template>
@@ -301,7 +389,7 @@
<div class="viewer-meta"></div>
</div>
<div class="grid-viewer-actions">
<button class="btn btn-secondary" id="gv-back">← Back</button>
<button class="btn btn-secondary" id="gv-back">← Back</button>
<button class="btn btn-secondary" id="gv-edit-panels">Edit panels</button>
<button class="btn btn-danger" id="gv-delete">Delete</button>
</div>
@@ -321,7 +409,7 @@
<!-- ===== DIALOG: COMPONENT ===== -->
<template id="t-dialog-component">
<dialog id="dialog-component" class="app-dialog">
<dialog id="dialog-component" class="app-dialog app-dialog-wide">
<h2 class="dialog-title"></h2>
<form method="dialog" id="form-component">
<div class="form-row">
@@ -335,11 +423,14 @@
<div class="form-section-label">Field values</div>
<div id="c-field-rows"></div>
<div class="form-row add-field-row">
<select id="c-add-field-select" class="filter-select">
<option value="">— add a field —</option>
</select>
<div class="input-with-action">
<select id="c-add-field-select" class="filter-select">
<option value="">— add a field —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="c-new-field">New…</button>
</div>
</div>
<div class="dialog-actions">
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="c-cancel">Cancel</button>
<button type="submit" class="btn btn-primary" id="c-save">Save</button>
</div>
@@ -349,14 +440,17 @@
<!-- ===== DIALOG: INVENTORY ENTRY ===== -->
<template id="t-dialog-inventory">
<dialog id="dialog-inventory" class="app-dialog">
<dialog id="dialog-inventory" class="app-dialog app-dialog-wide">
<h2 class="dialog-title"></h2>
<form method="dialog" id="form-inventory">
<div class="form-row">
<label for="i-component">Component</label>
<select id="i-component" required class="filter-select wide">
<option value="">— select component —</option>
</select>
<div class="input-with-action">
<select id="i-component" required class="filter-select">
<option value="">— select component —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="i-new-component">New…</button>
</div>
</div>
<div class="form-row">
<label for="i-type">Location type</label>
@@ -364,18 +458,26 @@
<option value="physical">Physical location</option>
<option value="bom">BOM / Drawing</option>
<option value="digital">Digital / Note</option>
<option value="grid">Grid cell</option>
</select>
</div>
<div class="form-row">
<label for="i-ref" id="i-ref-label">Location reference</label>
<input type="text" id="i-ref" autocomplete="off">
</div>
<div class="form-row" id="i-grid-row" hidden>
<label>Grid cell</label>
<div class="i-grid-cell-picker">
<select id="i-grid-select" class="filter-select"></select>
<div id="i-grid-visual" class="i-grid-visual"></div>
</div>
</div>
<div class="form-row">
<label for="i-qty">Quantity</label>
<input type="text" id="i-qty" autocomplete="off" placeholder="e.g. 10, ~50, see BOM">
</div>
<div class="form-row">
<label for="i-notes">Notes</label>
<label for="i-notes">Notes <span class="label-hint">(for this location entry)</span></label>
<input type="text" id="i-notes" autocomplete="off">
</div>
<div class="dialog-actions">
@@ -479,3 +581,259 @@
</div>
</dialog>
</template>
<!-- ===== DIALOG: FILE PICKER ===== -->
<template id="t-dialog-file-picker">
<dialog id="dialog-file-picker" class="app-dialog app-dialog-wide">
<h2 class="dialog-title">Files</h2>
<div id="fp-list" class="fp-list"></div>
<div class="fp-upload-section">
<div class="form-section-label">Upload new PDF</div>
<div class="fp-upload-row">
<input type="file" id="fp-file-input" accept=".pdf,application/pdf">
</div>
<div class="fp-upload-row">
<label class="fp-field-label">Display name</label>
<input type="text" id="fp-upload-name" placeholder="Human readable label" autocomplete="off">
</div>
<div class="fp-upload-row">
<label class="fp-field-label">Filename on disk</label>
<input type="text" id="fp-upload-filename" placeholder="e.g. lm741.pdf" autocomplete="off" spellcheck="false">
</div>
<div class="fp-upload-row">
<button type="button" class="btn btn-primary btn-sm" id="fp-upload-btn">Upload</button>
</div>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="fp-cancel">Close</button>
</div>
</dialog>
</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 ===== -->
<template id="t-cell-inventory">
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
<div class="cell-inventory-header">
<span class="cell-inventory-title"></span>
<button type="button" class="btn-icon" id="cell-inv-close"></button>
</div>
<div class="cell-inventory-list" id="cell-inventory-list"></div>
<div class="cell-inventory-actions">
<button type="button" class="btn btn-primary btn-sm" id="cell-inv-add">+ Add entry</button>
</div>
</div>
</template>

View File

@@ -14,18 +14,22 @@ export class Grid_Setup {
#cam_z = 1;
#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;
#pan_last = { x: 0, y: 0 };
#rows = 4;
#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) {
this.#canvas = canvas_el;
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('mouseup', e => this.#on_up(e));
canvas_el.addEventListener('mouseleave', () => { this.#drag_idx = -1; this.#panning = false; });
@@ -41,32 +45,37 @@ export class Grid_Setup {
const img = new Image();
img.onload = () => {
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
this.#css_w = max_w;
this.#css_h = max_h;
// Let CSS determine the width, then read back the actual rendered value.
// Using parentElement.clientWidth directly would include the parent's
// 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
this.#scale = Math.min(
(max_w * 0.9) / img.width,
(max_h * 0.9) / img.height,
(css_w * 0.9) / img.width,
(css_h * 0.9) / img.height,
);
const dpr = window.devicePixelRatio || 1;
this.#canvas.width = this.#css_w * dpr;
this.#canvas.height = this.#css_h * dpr;
this.#canvas.style.width = this.#css_w + 'px';
this.#canvas.style.height = this.#css_h + 'px';
this.#canvas.width = css_w * dpr;
this.#canvas.height = css_h * dpr;
this.#canvas.style.width = css_w + 'px';
this.#canvas.style.height = css_h + 'px';
this.#ctx.scale(dpr, dpr);
// Camera: start centered, image fitted within canvas
const img_w = img.width * this.#scale;
const img_h = img.height * this.#scale;
this.#cam_z = 1;
this.#cam_x = (max_w - img_w) / 2;
this.#cam_y = (max_h - img_h) / 2;
this.#cam_x = (css_w - img_w) / 2;
this.#cam_y = (css_h - img_h) / 2;
// Default corners: 15% inset in image coords
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 };
}
#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) {
if (!this.#corners) return -1;
// Corners take priority
for (let i = 0; i < 4; 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;
}
// 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;
}
#on_down(e, is_touch = false) {
if (!this.#corners) return;
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);
if (hit !== -1) {
this.#drag_idx = hit;
} else {
this.#panning = true;
this.#pan_last = sp;
if (!is_touch) this.#canvas.style.cursor = 'grabbing';
this.#drag_prev_img = this.#world_to_img(this.#to_world(sp));
}
}
@@ -152,10 +181,27 @@ export class Grid_Setup {
const sp = this.#screen_pos(e);
if (this.#drag_idx !== -1) {
const img_pos = this.#world_to_img(this.#to_world(sp));
this.#corners[this.#drag_idx] = {
x: Math.max(0, Math.min(this.#img.width, img_pos.x)),
y: Math.max(0, Math.min(this.#img.height, img_pos.y)),
};
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();
} else if (this.#panning) {
this.#cam_x += sp.x - this.#pan_last.x;
@@ -170,6 +216,7 @@ export class Grid_Setup {
#on_up(e) {
this.#drag_idx = -1;
this.#drag_prev_img = null;
if (this.#panning) {
this.#panning = false;
const sp = this.#screen_pos(e);
@@ -258,6 +305,19 @@ export class Grid_Setup {
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();
}
}

View File

@@ -3,11 +3,12 @@ process.on('uncaughtException', (err) => { console.error('[uncaughtException
import express from 'express';
import multer from 'multer';
import { unlinkSync, mkdirSync } from 'node:fs';
import { unlinkSync, mkdirSync, existsSync } from 'node:fs';
import { extname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import sharp from 'sharp';
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 {
list_fields, get_field, set_field, delete_field,
list_components, get_component, set_component, delete_component,
@@ -15,9 +16,37 @@ import {
list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft,
list_source_images, get_source_image, add_source_image, delete_source_image,
list_grid_images, get_grid_image, set_grid_image, delete_grid_image,
list_component_templates, get_component_template, set_component_template, delete_component_template,
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';
mkdirSync('./data/images', { 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();
app.use(express.json());
@@ -38,10 +67,77 @@ const upload = multer({
limits: { fileSize: 20 * 1024 * 1024 },
});
const pdf_upload = multer({
storage: multer.diskStorage({
destination: './data/pdfs',
filename: (req, file, cb) => cb(null, generate_id() + '.pdf'),
}),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf' || extname(file.originalname).toLowerCase() === '.pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'));
}
},
});
app.use('/pdf', express.static('./data/pdfs'));
function remove_image_file(img_id) {
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;
// Atomically rename src -> dst, failing if dst already exists.
// Uses renameat2(RENAME_NOREPLACE) via tools/mv-sync.
// Throws on unexpected errors; returns false if dst exists.
function rename_no_replace(src, dst) {
try {
execFileSync(MV_SYNC, [src, dst]);
return true;
} catch (e) {
if (e.status === 1) return false;
throw e;
}
}
function sanitize_pdf_filename(display_name) {
const base = display_name
.replace(/\.pdf$/i, '')
.replace(/[^a-zA-Z0-9._\- ]/g, '')
.trim()
.replace(/\s+/g, '_')
|| 'document';
return base + '.pdf';
}
function generate_pdf_thumb(pdf_path, thumb_prefix) {
// Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable.
try {
execFileSync('pdftoppm', [
'-png', '-singlefile', '-scale-to', '512',
'-f', '1', '-l', '1',
pdf_path, thumb_prefix,
]);
return thumb_prefix.split('/').pop() + '.png';
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Field definitions
// ---------------------------------------------------------------------------
@@ -115,11 +211,12 @@ app.get('/api/components/:id', (req, res) => {
app.put('/api/components/:id', (req, res) => {
const existing = get_component(req.params.id);
if (!existing) return fail(res, 'not found', 404);
const { name, description, fields } = req.body;
const { name, description, fields, file_ids } = req.body;
const updated = { ...existing, updated_at: Date.now() };
if (name !== undefined) updated.name = name.trim();
if (description !== undefined) updated.description = description.trim();
if (fields !== undefined) updated.fields = fields;
if (file_ids !== undefined) updated.file_ids = file_ids;
set_component(updated);
ok(res, { component: updated });
});
@@ -156,7 +253,8 @@ app.get('/api/inventory', (req, res) => {
});
app.post('/api/inventory', (req, res) => {
const { component_id, location_type, location_ref = '', quantity = '', notes = '' } = req.body;
const { component_id, location_type, location_ref = '', quantity = '', notes = '',
grid_id = null, grid_row = null, grid_col = null } = req.body;
if (!component_id) return fail(res, 'component_id is required');
if (!location_type) return fail(res, 'location_type is required');
if (!get_component(component_id)) return fail(res, 'component not found', 404);
@@ -168,6 +266,9 @@ app.post('/api/inventory', (req, res) => {
location_ref: String(location_ref).trim(),
quantity: String(quantity).trim(),
notes: String(notes).trim(),
grid_id: grid_id ?? null,
grid_row: grid_row != null ? parseInt(grid_row) : null,
grid_col: grid_col != null ? parseInt(grid_col) : null,
images: [],
created_at: now,
updated_at: now,
@@ -185,6 +286,9 @@ app.put('/api/inventory/:id', (req, res) => {
if (location_ref !== undefined) updated.location_ref = String(location_ref).trim();
if (quantity !== undefined) updated.quantity = String(quantity).trim();
if (notes !== undefined) updated.notes = String(notes).trim();
if (req.body.grid_id !== undefined) updated.grid_id = req.body.grid_id ?? null;
if (req.body.grid_row !== undefined) updated.grid_row = req.body.grid_row != null ? parseInt(req.body.grid_row) : null;
if (req.body.grid_col !== undefined) updated.grid_col = req.body.grid_col != null ? parseInt(req.body.grid_col) : null;
set_inventory_entry(updated);
ok(res, { entry: updated });
});
@@ -260,12 +364,14 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
if (!req.files?.length) return fail(res, 'no files');
const added = [];
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 = {
id: file.filename,
id: final_name,
original_name: file.originalname,
width: meta.width,
height: meta.height,
uses: ['grid'],
created_at: Date.now(),
};
add_source_image(src);
@@ -274,15 +380,26 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
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) => {
const id = req.params.id;
if (!get_source_image(id)) return fail(res, 'not found', 404);
const grids = list_grid_images();
const in_use = grids.find(g =>
const in_grid = list_grid_images().find(g =>
g.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);
delete_source_image(id);
ok(res);
@@ -416,6 +533,408 @@ app.delete('/api/grid-images/:id', (req, res) => {
ok(res);
});
// ---------------------------------------------------------------------------
// Component templates
// ---------------------------------------------------------------------------
app.get('/api/component-templates', (req, res) => {
ok(res, { templates: list_component_templates() });
});
app.post('/api/component-templates', (req, res) => {
const { name, formatter } = req.body;
if (!name) return fail(res, 'name is required');
const tmpl = { id: generate_id(), name, formatter: formatter ?? '', created_at: Date.now(), updated_at: Date.now() };
set_component_template(tmpl);
ok(res, { template: tmpl });
});
app.put('/api/component-templates/:id', (req, res) => {
const existing = get_component_template(req.params.id);
if (!existing) return fail(res, 'not found', 404);
const updated = { ...existing, updated_at: Date.now() };
if (req.body.name !== undefined) updated.name = req.body.name;
if (req.body.formatter !== undefined) updated.formatter = req.body.formatter;
set_component_template(updated);
ok(res, { template: updated });
});
app.delete('/api/component-templates/:id', (req, res) => {
if (!get_component_template(req.params.id)) return fail(res, 'not found', 404);
delete_component_template(req.params.id);
ok(res);
});
// ---------------------------------------------------------------------------
// PDF files
// ---------------------------------------------------------------------------
app.get('/api/pdfs', (req, res) => {
ok(res, { pdfs: list_pdfs() });
});
app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => {
if (!req.file) return fail(res, 'no file uploaded');
const display_name = req.body.display_name?.trim() || req.file.originalname;
const filename = sanitize_pdf_filename(req.body.filename?.trim() || req.file.originalname);
const all = list_pdfs();
if (all.some(p => p.display_name === display_name)) {
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
return fail(res, 'a file with that display name already exists');
}
if (all.some(p => p.filename === filename)) {
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
return fail(res, 'a file with that filename already exists');
}
const id = generate_id();
const temp_path = join('./data/pdfs', req.file.filename);
const final_path = join('./data/pdfs', filename);
if (!rename_no_replace(temp_path, final_path)) {
try { unlinkSync(temp_path); } catch {}
return fail(res, 'a file with that filename already exists on disk');
}
const thumb_prefix = join('./data/pdfs', id + '-thumb');
const thumb_file = generate_pdf_thumb(final_path, thumb_prefix);
const pdf = { id, filename, display_name, original_name: req.file.originalname, size: req.file.size, thumb_filename: thumb_file, uploaded_at: Date.now() };
set_pdf(pdf);
ok(res, { pdf });
});
app.put('/api/pdfs/:id', (req, res) => {
const pdf = get_pdf(req.params.id);
if (!pdf) return fail(res, 'not found', 404);
const display_name = req.body.display_name?.trim();
const filename = req.body.filename?.trim() ? sanitize_pdf_filename(req.body.filename.trim()) : pdf.filename;
if (!display_name) return fail(res, 'display_name is required');
const all = list_pdfs();
if (all.some(p => p.display_name === display_name && p.id !== pdf.id))
return fail(res, 'a file with that display name already exists');
if (all.some(p => p.filename === filename && p.id !== pdf.id))
return fail(res, 'a file with that filename already exists');
if (filename !== pdf.filename) {
const new_path = join('./data/pdfs', filename);
if (!rename_no_replace(join('./data/pdfs', pdf.filename), new_path))
return fail(res, 'a file with that filename already exists on disk');
}
const updated = { ...pdf, display_name, filename };
set_pdf(updated);
ok(res, { pdf: updated });
});
app.delete('/api/pdfs/:id', (req, res) => {
const pdf = get_pdf(req.params.id);
if (!pdf) return fail(res, 'not found', 404);
try { unlinkSync(join('./data/pdfs', pdf.filename)); } catch {}
if (pdf.thumb_filename) { try { unlinkSync(join('./data/pdfs', pdf.thumb_filename)); } catch {} }
delete_pdf(req.params.id);
ok(res);
});
// ---------------------------------------------------------------------------
// 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) => {
const pdfs = list_pdfs();
let generated = 0;
for (const pdf of pdfs) {
if (pdf.thumb_filename) continue;
const thumb_prefix = join('./data/pdfs', pdf.id + '-thumb');
const thumb_file = generate_pdf_thumb(join('./data/pdfs', pdf.filename), thumb_prefix);
if (thumb_file) {
set_pdf({ ...pdf, thumb_filename: thumb_file });
generated++;
}
}
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
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));

10
tools/Makefile Normal file
View File

@@ -0,0 +1,10 @@
CC = gcc
CFLAGS = -O2 -Wall -Wextra
all: mv-sync
mv-sync: mv-sync.c
$(CC) $(CFLAGS) -o mv-sync mv-sync.c
clean:
rm -f mv-sync

42
tools/mv-sync.c Normal file
View File

@@ -0,0 +1,42 @@
/*
* mv-sync: atomic rename that fails if the destination already exists.
*
* Uses renameat2(2) with RENAME_NOREPLACE (Linux 3.15+).
*
* Exit codes:
* 0 success
* 1 rename failed (reason on stderr)
* 2 wrong number of arguments
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#ifndef RENAME_NOREPLACE
#define RENAME_NOREPLACE (1 << 0)
#endif
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "usage: mv-sync <src> <dst>\n");
return 2;
}
long ret = syscall(SYS_renameat2,
AT_FDCWD, argv[1],
AT_FDCWD, argv[2],
RENAME_NOREPLACE);
if (ret != 0) {
fprintf(stderr, "mv-sync: rename \"%s\" -> \"%s\": %s\n",
argv[1], argv[2], strerror(errno));
return 1;
}
return 0;
}

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.