Compare commits

..

25 Commits

Author SHA1 Message Date
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
12 changed files with 2170 additions and 37 deletions

392
CLAUDE.md Normal file
View File

@@ -0,0 +1,392 @@
# 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*
---
## Known limitations / planned rewrite notes
See [`future-plans.md`](future-plans.md) for full detail. Key points relevant
to coding decisions:
- `app.mjs` will be split into per-section view modules
- KV store will become hierarchical (no more prefix convention)
- IDs will migrate to sequential integers
- CSS will be split per section with a build-step concatenation
- SSE for live updates across devices
- Generic fields on all entity types (already done for components, bins, bin types)
- A complete rewrite is planned; this codebase is the learning prototype

View File

@@ -7,12 +7,37 @@
`fs-views`, `publication-tool`). Should live in its own Gitea repo as an installable `fs-views`, `publication-tool`). Should live in its own Gitea repo as an installable
npm package (`npm install git+https://...`) so changes propagate rather than drift. npm package (`npm install git+https://...`) so changes propagate rather than drift.
### Hierarchical storage structure
The current store is a flat string→value map with prefixed keys (`f:`, `c:`, `bin:`,
etc.) as a manual namespacing convention. This should be replaced with a proper tree:
collections as top-level keys whose values are `Record<id, object>`. Eliminates the
prefix convention, makes collection access direct and self-documenting, and removes
the full-scan `startsWith` pattern from every `list_*` function. Requires a one-time
migration of existing NDJSON data. Best done as part of the shared library rewrite.
### Delta / revision tracking ### Delta / revision tracking
Add a delta log alongside the main snapshot file (e.g. `inventory.ndjson.deltas`) Add a delta log alongside the main snapshot file (e.g. `inventory.ndjson.deltas`)
that records every `set`/`delete` as a timestamped entry. The main file stays a that records every `set`/`delete` as a timestamped entry. The main file stays a
clean current-state snapshot; the delta file accumulates the full history. Enables clean current-state snapshot; the delta file accumulates the full history. Enables
undo, audit trails, and debugging data corruption. undo, audit trails, and debugging data corruption.
## Real-time / live updates
### Server-sent events for data changes
When data changes on the server (upload, edit, delete — from any client), connected
browsers should receive a notification and update the affected view automatically.
Use case: uploading photos from a phone while the desktop browser has the Images or
Bins section open.
Server-sent events (SSE) are the natural fit — lightweight, one-directional, no
library needed. The server emits a change event with a `type` (e.g. `source_images`,
`bins`) and the client re-fetches and re-renders only the affected collection.
Views that aren't currently visible don't need to do anything — they'll reload on
next navigation.
Ties directly into the delta tracking plan: the same write path that appends to the
delta log can also fan out to connected SSE clients.
## App architecture ## App architecture
### parse_url mutates too many module-level variables ### parse_url mutates too many module-level variables
@@ -49,6 +74,13 @@ function render() { sync_nav(); SECTION_RENDERERS[section]?.(); }
(`views/components.mjs`, `views/grids.mjs`, etc.) that each export their render (`views/components.mjs`, `views/grids.mjs`, etc.) that each export their render
function and own their local state. function and own their local state.
### Split CSS into per-section files
`style.css` is a single large file and getting hard to navigate. Split into
per-section files (`components.css`, `grids.css`, `bins.css`, etc.) plus a
`base.css` for variables, resets, and shared layout. A `make build` step can
concatenate them into a single `style.css` for deployment, keeping the dev
experience clean without adding a bundler dependency.
### Explicit save in component editor ### Explicit save in component editor
Currently any change in the component detail panel (linking a file, unlinking an Currently any change in the component detail panel (linking a file, unlinking an
inventory entry, etc.) is persisted immediately. This makes it hard to experiment or inventory entry, etc.) is persisted immediately. This makes it hard to experiment or
@@ -174,6 +206,13 @@ Some fields naturally belong together (e.g. `frequency_stability` and
Structured records are the more powerful option but require a schema system and Structured records are the more powerful option but require a schema system and
more complex UI. Grouping/linkage is a lighter short-term win. more complex UI. Grouping/linkage is a lighter short-term win.
As fields are shared across entity types (components, bins, bin types, and anything
else added later), the field pool grows to span unrelated domains. Groups also serve
as a domain filter in the field selector — when adding a field to a bin type, you
should be able to filter to e.g. "physical" or "storage" fields rather than seeing
electrical component fields mixed in. Each field should be able to belong to one or
more groups.
#### Semantically-aware formatting (acronyms, proper names) #### Semantically-aware formatting (acronyms, proper names)
Formatters that apply title case or similar text transformations can corrupt acronyms Formatters that apply title case or similar text transformations can corrupt acronyms
(e.g. `NPN``Npn`) or brand/proper names. The root cause is that free-text field (e.g. `NPN``Npn`) or brand/proper names. The root cause is that free-text field
@@ -374,6 +413,37 @@ used/visited locations at the top so you can quickly re-select where you just we
Useful when processing a batch of components into the same storage location — you Useful when processing a batch of components into the same storage location — you
shouldn't have to navigate the grid picker from scratch each time. shouldn't have to navigate the grid picker from scratch each time.
## Bins
### Bin types
Define reusable bin type records (e.g. "Sortimo L-Boxx insert small", "Wago 221
connector box") that store physical dimensions (mm), and optionally a default
compartment layout. When creating or editing a bin, the user picks a type and the
dimensions are pre-filled — no need to re-enter for every bin of the same model.
This also enables filtering/grouping bins by type, and makes it easy to re-process
all bins of a type if the corner algorithm improves.
### Generic fields on bins and bin types
Bins and bin types should both support the same generic field system as components —
arbitrary key/value pairs from the shared field definitions. Examples: color, material,
manufacturer, max load, purchase link. Bin types carry the "template" fields (e.g.
nominal dimensions from the datasheet) while individual bins carry instance-specific
fields (e.g. actual color of that specific unit).
Because fields are shared across components, bins, and anything else that grows into
the system, they will quickly span unrelated domains. Field grouping (see Field system
section) becomes important here so the field selector can be filtered to show only
relevant fields for the current entity type.
### Duplicate any entity
All objects in the system should be duplicatable: components, bin types, bins, grids,
templates, inventory entries, and eventually source images. The duplicate operation
creates a new record with all fields copied, then opens it in an edit dialog so the
user can adjust what differs. Bin type duplication is especially common — same
physical container model in different colors or configurations. Source images are a
later case since they reference uploaded files; duplication there would mean creating
a new metadata record pointing to the same underlying file (or an explicit copy).
## Grids ## Grids
### Grid view layers ### Grid view layers

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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