Compare commits
25 Commits
80a2fabf7d
...
72897c5b2d
| Author | SHA1 | Date | |
|---|---|---|---|
| 72897c5b2d | |||
| e2d0079ba0 | |||
| 46ce10289e | |||
| 33c8ff274e | |||
| 2b7d50a53d | |||
| 0319ff7099 | |||
| 5e2a348b9d | |||
| b0eaf4dc10 | |||
| 046fe99c72 | |||
| ede87bb90f | |||
| 7670db2c6e | |||
| 1aa7350c4d | |||
| b200a7ec8d | |||
| 7e70864907 | |||
| 090f6f3154 | |||
| 320c6f1bd9 | |||
| 1ea14f8953 | |||
| c41fb42e16 | |||
| 871ad7124a | |||
| 53bd086661 | |||
| 38c2d89c9b | |||
| e183988acb | |||
| 28b4590903 | |||
| f370b6d48d | |||
| 67369b56be |
392
CLAUDE.md
Normal file
392
CLAUDE.md
Normal 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):**
|
||||||
|
```
|
||||||
|
1–90 Imports, state vars, startup data load
|
||||||
|
91–250 Helper functions (field rendering, search, formatters)
|
||||||
|
251–700 Components section (list, detail panel, edit dialog)
|
||||||
|
701–1000 Inventory section
|
||||||
|
1001–1600 Grids section (list, draft editor, grid viewer)
|
||||||
|
1601–1900 Component templates, field definitions dialogs
|
||||||
|
1901–2000 Images admin section
|
||||||
|
2001–2450 Bins section (list, source list, types list, bin editor)
|
||||||
|
2450–2500 Routing (parse_url, navigate, render)
|
||||||
|
2500–end 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
644
public/app.mjs
644
public/app.mjs
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
300
public/style.css
300
public/style.css
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
349
server.mjs
349
server.mjs
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user