Compare commits
99 Commits
cf37759893
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 270806539c | |||
| 5fe7273e35 | |||
| 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 | |||
| 80a2fabf7d | |||
| e83d3978b0 | |||
| 34dc1d441c | |||
| 6874b9482a | |||
| 55f8766176 | |||
| 2405be6a66 | |||
| 5ac980c9fa | |||
| a6bd340d81 | |||
| eeb77babbb | |||
| 5681d5f024 | |||
| 210fb1e037 | |||
| 110e17e972 | |||
| 84dc06f365 | |||
| 7265b5bb2c | |||
| 27bf6043d3 | |||
| 1bebf7a12b | |||
| 85170d4b50 | |||
| 94b20dda6b | |||
| 88cc71b7d3 | |||
| 06b2691d87 | |||
| 956f168578 | |||
| 07dbb6261e | |||
| a17bafb6d3 | |||
| 8cb1d11e40 | |||
| 98190c5271 | |||
| e7653eda83 | |||
| 51d1a23406 | |||
| 31106691d4 | |||
| e61b6cd548 | |||
| 4813a65a53 | |||
| d3df99a8f0 | |||
| 1fbd6403ab | |||
| 13ab5867c7 | |||
| 58c93f2bd0 | |||
| 7ef5bb5381 | |||
| 08b8e2dd4d | |||
| d489c1e306 | |||
| bc339bd073 | |||
| ad96a53246 | |||
| 258f9b6491 | |||
| e1c517c023 | |||
| 8fa4a54f9e | |||
| 64af0862f2 | |||
| 488fa7ff53 | |||
| 4a210047c8 | |||
| 1d3a157d75 | |||
| cdefa70bd7 | |||
| 8e0f7eb4d8 | |||
| 451b04ad03 | |||
| 61d52d8076 | |||
| f0bedc80a7 | |||
| e91a656dc8 | |||
| dc0e822e9b | |||
| b9ba6d38b5 | |||
| 754f8504f1 | |||
| faed2f8296 | |||
| 878b32f9e5 | |||
| 520728c62b | |||
| 91630d35e6 | |||
| d8905902e7 | |||
| f7c4dc10b8 | |||
| 590bad3374 | |||
| 38dba05ac0 | |||
| 99299ed9f2 | |||
| b66b2f95d3 | |||
| 896b6fcb39 | |||
| 64157013ed | |||
| 3675c1725a | |||
| 57c697cbfc | |||
| 27970e74f9 | |||
| 08501539dd | |||
| 6c37912ec5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
data/
|
data/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
tools/mv-sync
|
||||||
400
CLAUDE.md
Normal file
400
CLAUDE.md
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
# CLAUDE.md — Electronics Inventory
|
||||||
|
|
||||||
|
Agent orientation file. Read this before touching code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
A self-hosted electronics inventory web app. Tracks components, PDFs/datasheets,
|
||||||
|
physical storage grids (photographed and de-perspectived), and bins. Single-user,
|
||||||
|
no auth. Node.js + Express 5 backend, vanilla JS SPA frontend, flat NDJSON
|
||||||
|
key-value store for persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
```
|
||||||
|
server.mjs Entry point. All Express routes. ~940 lines.
|
||||||
|
lib/
|
||||||
|
storage.mjs KV store CRUD wrappers (one section per entity type).
|
||||||
|
Read this to understand what data exists and its prefix.
|
||||||
|
kv-store.mjs Flat NDJSON key-value store (Simple_KeyValue_Store class).
|
||||||
|
Auto-loads on construct, auto-flushes with debounce.
|
||||||
|
grid-image.mjs Image processing: de-perspective a photo into a grid of
|
||||||
|
cell images using sharp. compute_bin_size() lives here.
|
||||||
|
ids.mjs generate_id() — timestamp-base36 + random suffix.
|
||||||
|
(Planned: migrate to sequential integers.)
|
||||||
|
public/
|
||||||
|
app.mjs SPA. All rendering, routing, dialog logic. ~2600 lines.
|
||||||
|
Sections separated by // --- comments.
|
||||||
|
lib/api.mjs All fetch wrappers. Read this for the full API surface.
|
||||||
|
lib/dom.mjs Tiny DOM helpers: qs(), clone(), show(), hide().
|
||||||
|
views/grid-setup.mjs Grid_Setup class — canvas corner editor with pan/zoom.
|
||||||
|
Used for both grid source images and bin corner editing.
|
||||||
|
templates.html All HTML templates (id="t-*"). Injected into body at init.
|
||||||
|
style.css All styles. Single file (planned: split per section).
|
||||||
|
index.html Shell. Loads app.mjs as module. Nav buttons hardcoded.
|
||||||
|
tools/
|
||||||
|
mv-sync.c / mv-sync renameat2(RENAME_NOREPLACE) binary for atomic rename
|
||||||
|
without overwrite. Used by settle_image_filename().
|
||||||
|
Makefile Builds mv-sync.
|
||||||
|
data/
|
||||||
|
inventory.ndjson The database. All entities in one flat KV file.
|
||||||
|
images/ All uploaded and processed images.
|
||||||
|
pdfs/ Uploaded PDF files.
|
||||||
|
thumbs/ PDF thumbnails (generated by pdftoppm).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KV store — key prefixes
|
||||||
|
|
||||||
|
| Prefix | Entity | Storage function family |
|
||||||
|
|---------|---------------------|---------------------------|
|
||||||
|
| `f:` | Field definitions | get_field / set_field |
|
||||||
|
| `c:` | Components | get_component / set_component |
|
||||||
|
| `i:` | Inventory entries | get_inventory_entry / set_inventory_entry |
|
||||||
|
| `d:` | Grid drafts | get_grid_draft / set_grid_draft |
|
||||||
|
| `s:` | Source images | get_source_image / add_source_image |
|
||||||
|
| `g:` | Grid images | get_grid_image / set_grid_image |
|
||||||
|
| `ct:` | Component templates | get_component_template / set_component_template |
|
||||||
|
| `pdf:` | PDF files | get_pdf / set_pdf |
|
||||||
|
| `bt:` | Bin types | get_bin_type / set_bin_type |
|
||||||
|
| `bin:` | Bins | get_bin / set_bin |
|
||||||
|
|
||||||
|
All `list_*()` functions do a full-scan `startsWith(prefix)` over the store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model shapes
|
||||||
|
|
||||||
|
### Field definition (`f:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string, // generate_id()
|
||||||
|
name: string, // e.g. 'resistance'
|
||||||
|
unit: string, // e.g. 'Ω' — optional
|
||||||
|
description: string,
|
||||||
|
created_at: number, // ms timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component (`c:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
fields: { [field_id]: string }, // values keyed by field definition id
|
||||||
|
images: string[], // filenames in data/images/
|
||||||
|
file_ids: string[], // linked PDF ids
|
||||||
|
created_at: number,
|
||||||
|
updated_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inventory entry (`i:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
component_id: string,
|
||||||
|
location_type: 'physical' | 'bom' | 'digital' | 'grid',
|
||||||
|
location_ref: string, // free text for physical/bom/digital
|
||||||
|
quantity: string,
|
||||||
|
notes: string,
|
||||||
|
grid_id: string | null, // set when location_type === 'grid'
|
||||||
|
grid_row: number | null,
|
||||||
|
grid_col: number | null,
|
||||||
|
images: string[],
|
||||||
|
created_at: number,
|
||||||
|
updated_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source image (`s:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string, // filename in data/images/ (used as key too)
|
||||||
|
original_name: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
uses: ('grid' | 'bin')[], // which features reference this image
|
||||||
|
created_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid draft (`d:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
source_id: string, // source image filename
|
||||||
|
rows: number,
|
||||||
|
cols: number,
|
||||||
|
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
|
||||||
|
created_at: number,
|
||||||
|
updated_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid image (`g:`) — result of processing a grid draft
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
source_id: string,
|
||||||
|
rows: number,
|
||||||
|
cols: number,
|
||||||
|
corners: [{x,y}, ...],
|
||||||
|
panels: [[{ filename, component_id?, notes? }, ...], ...], // [row][col]
|
||||||
|
created_at: number,
|
||||||
|
updated_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component template (`ct:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
formatter: string, // JS function body string, compiled at runtime
|
||||||
|
created_at: number,
|
||||||
|
updated_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PDF (`pdf:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
display_name: string,
|
||||||
|
filename: string, // in data/pdfs/
|
||||||
|
thumb_prefix: string, // in data/thumbs/ — pdftoppm output prefix
|
||||||
|
created_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bin type (`bt:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
phys_w: number, // mm
|
||||||
|
phys_h: number, // mm
|
||||||
|
description: string,
|
||||||
|
fields: { [field_id]: string },
|
||||||
|
created_at: number,
|
||||||
|
updated_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bin (`bin:`)
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
type_id: string | null, // ref to bin type
|
||||||
|
source_id: string, // source image filename (always kept)
|
||||||
|
source_w: number,
|
||||||
|
source_h: number,
|
||||||
|
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
|
||||||
|
phys_w: number | null, // mm — null means infer from corners
|
||||||
|
phys_h: number | null,
|
||||||
|
image_filename: string | null, // processed output in data/images/; null if not yet processed
|
||||||
|
bin_w: number | null, // px dimensions of processed output
|
||||||
|
bin_h: number | null,
|
||||||
|
fields: { [field_id]: string },
|
||||||
|
contents: [ // embedded content items
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
type: 'component' | 'item',
|
||||||
|
component_id: string | null, // set when type === 'component'
|
||||||
|
name: string | null, // set when type === 'item'
|
||||||
|
quantity: string,
|
||||||
|
notes: string,
|
||||||
|
created_at: number,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
created_at: number,
|
||||||
|
updated_at: number,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API routes (server.mjs)
|
||||||
|
|
||||||
|
All responses: `{ ok: true, ...data }` or `{ ok: false, error: string }`.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/fields
|
||||||
|
POST /api/fields body: { name, unit?, description? }
|
||||||
|
PUT /api/fields/:id
|
||||||
|
DELETE /api/fields/:id
|
||||||
|
|
||||||
|
GET /api/components
|
||||||
|
POST /api/components body: { name, description?, fields? }
|
||||||
|
GET /api/components/:id
|
||||||
|
PUT /api/components/:id body: { name?, description?, fields?, file_ids? }
|
||||||
|
DELETE /api/components/:id
|
||||||
|
POST /api/components/:id/images multipart: images[]
|
||||||
|
DELETE /api/components/:id/images/:img_id
|
||||||
|
|
||||||
|
GET /api/inventory
|
||||||
|
POST /api/inventory body: { component_id, location_type, ... }
|
||||||
|
PUT /api/inventory/:id
|
||||||
|
DELETE /api/inventory/:id
|
||||||
|
POST /api/inventory/:id/images multipart: images[]
|
||||||
|
DELETE /api/inventory/:id/images/:img_id
|
||||||
|
|
||||||
|
GET /api/grid-drafts
|
||||||
|
POST /api/grid-drafts body: { source_id, rows, cols }
|
||||||
|
PUT /api/grid-drafts/:id
|
||||||
|
DELETE /api/grid-drafts/:id
|
||||||
|
|
||||||
|
GET /api/source-images
|
||||||
|
POST /api/source-images multipart: images[] — creates source records (uses: ['grid'])
|
||||||
|
PUT /api/source-images/:id body: { uses }
|
||||||
|
DELETE /api/source-images/:id guarded: refused if any grid/bin still references it
|
||||||
|
|
||||||
|
GET /api/grid-images
|
||||||
|
GET /api/grid-images/:id
|
||||||
|
POST /api/grid-images body: { draft_id } — processes draft → grid image
|
||||||
|
PUT /api/grid-images/:id/panels/:pi body: { component_id?, notes? }
|
||||||
|
DELETE /api/grid-images/:id
|
||||||
|
|
||||||
|
GET /api/component-templates
|
||||||
|
POST /api/component-templates body: { name, formatter }
|
||||||
|
PUT /api/component-templates/:id
|
||||||
|
DELETE /api/component-templates/:id
|
||||||
|
|
||||||
|
GET /api/pdfs
|
||||||
|
POST /api/pdfs multipart: file, display_name, filename
|
||||||
|
PUT /api/pdfs/:id body: { display_name, filename }
|
||||||
|
DELETE /api/pdfs/:id guarded: refused if any component references it
|
||||||
|
|
||||||
|
GET /api/bin-types
|
||||||
|
POST /api/bin-types body: { name, phys_w, phys_h, description?, fields? }
|
||||||
|
PUT /api/bin-types/:id
|
||||||
|
DELETE /api/bin-types/:id guarded: refused if any bin references it
|
||||||
|
|
||||||
|
GET /api/bins
|
||||||
|
GET /api/bins/:id
|
||||||
|
POST /api/bins multipart: image, name?, type_id? — upload + create
|
||||||
|
POST /api/bins/from-source body: { source_id, name?, type_id? }
|
||||||
|
PUT /api/bins/:id body: { name?, type_id?, fields? }
|
||||||
|
PUT /api/bins/:id/corners body: { corners, phys_w?, phys_h? } — triggers reprocess
|
||||||
|
DELETE /api/bins/:id only deletes processed image_filename, not source
|
||||||
|
POST /api/bins/:id/contents body: { type, component_id?, name?, quantity, notes }
|
||||||
|
PUT /api/bins/:id/contents/:cid body: { quantity?, notes?, name? }
|
||||||
|
DELETE /api/bins/:id/contents/:cid
|
||||||
|
|
||||||
|
POST /api/maintenance/purge-missing-sources removes source KV entries whose files are gone
|
||||||
|
POST /api/maintenance/pdf-thumbs regenerates missing PDF thumbnails
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (app.mjs) structure
|
||||||
|
|
||||||
|
Module-level state variables at the top (`all_components`, `all_fields`, etc.).
|
||||||
|
All loaded once at startup via parallel API calls, mutated in place on changes.
|
||||||
|
|
||||||
|
**Key patterns:**
|
||||||
|
- `clone('t-template-id')` — clones a `<template>` into a live element
|
||||||
|
- `qs(el, '#id')` — scoped querySelector
|
||||||
|
- Dialog callbacks stored as module-level `let x_dialog_callback = null`,
|
||||||
|
set by the open function, called by the init-registered submit/save handler
|
||||||
|
- `render()` — top-level re-render, called after navigation or data changes
|
||||||
|
- `navigate(path)` — pushes history + calls render()
|
||||||
|
- `build_field_editor(rows_el, sel_el, new_btn_el, initial_fields)` — shared
|
||||||
|
helper that wires up field row editing; returns `{ get_fields() }`
|
||||||
|
|
||||||
|
**Section layout (by line range, approximate):**
|
||||||
|
```
|
||||||
|
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*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI structure
|
||||||
|
|
||||||
|
See [`ui-structure.md`](ui-structure.md) for the full inventory of sections,
|
||||||
|
widget patterns, dialogs, templates, and a taxonomy of primitives relevant to
|
||||||
|
designing a higher-level UI representation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations / planned rewrite notes
|
||||||
|
|
||||||
|
See [`future-plans.md`](future-plans.md) for full detail. Key points relevant
|
||||||
|
to coding decisions:
|
||||||
|
|
||||||
|
- `app.mjs` will be split into per-section view modules
|
||||||
|
- KV store will become hierarchical (no more prefix convention)
|
||||||
|
- IDs will migrate to sequential integers
|
||||||
|
- CSS will be split per section with a build-step concatenation
|
||||||
|
- SSE for live updates across devices
|
||||||
|
- Generic fields on all entity types (already done for components, bins, bin types)
|
||||||
|
- A complete rewrite is planned; this codebase is the learning prototype
|
||||||
166
README.md
Normal file
166
README.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Electronics Inventory
|
||||||
|
|
||||||
|
A self-hosted web app for managing an electronics component collection. Track components, their
|
||||||
|
field values, physical storage locations, and visual panel/grid layouts.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Components
|
||||||
|
Define reusable component types (e.g. Resistor, Capacitor, IC) with custom fields per component.
|
||||||
|
Components show up in inventory entries and can be navigated to directly from storage locations.
|
||||||
|
|
||||||
|
- Word-split search: searching `0603 res` matches `Resistor 0603`
|
||||||
|
- Selected component reflected in URL (`/components/:id`) — survives page refresh
|
||||||
|
- Resizable list pane (width persisted in localStorage)
|
||||||
|
- Duplicate button to quickly clone a component
|
||||||
|
- Field values sorted alphabetically, rendered centrally (units, URLs as links, extensible)
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
Create custom field definitions (e.g. `resistance`, `capacitance`, `package`) with an optional
|
||||||
|
unit suffix. Unit is appended directly to the value with no space, so you can write `4k7` for a
|
||||||
|
resistance field with unit `Ω` and it displays as `4k7Ω`.
|
||||||
|
|
||||||
|
URL-like field values (beginning with `http://` or `https://`) are automatically rendered as
|
||||||
|
clickable links.
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
Log where things are stored. Each inventory entry links a component to a storage location.
|
||||||
|
Supported location types:
|
||||||
|
|
||||||
|
- **Grid cell** — a specific row/column in a named grid (e.g. drawer divider box), picked
|
||||||
|
visually with a graphical cell picker
|
||||||
|
- *(plain entries without a grid reference work too)*
|
||||||
|
|
||||||
|
Notes on inventory entries are per storage location (not per component).
|
||||||
|
|
||||||
|
### Grids
|
||||||
|
Model physical storage grids (drawer organizers, parts boxes, etc.).
|
||||||
|
|
||||||
|
1. Upload a photo of the grid
|
||||||
|
2. Set up corner points to map the image to a logical grid (corners can extend outside image bounds)
|
||||||
|
3. Define row/column counts
|
||||||
|
4. Click any cell to see what's stored there — component entries are links, middle-click opens in new tab
|
||||||
|
5. Each cell shows a green count badge of how many components reference it
|
||||||
|
6. Navigating to a grid from a component detail highlights and scrolls to the relevant cell
|
||||||
|
|
||||||
|
### PDF Attachments
|
||||||
|
Attach PDF datasheets or other documents to components.
|
||||||
|
|
||||||
|
- PDFs are stored with a sanitized human-readable filename derived from the display name
|
||||||
|
- Rename a PDF and the file on disk is also renamed, atomically (uses `renameat2 RENAME_NOREPLACE`
|
||||||
|
via `tools/mv-sync`)
|
||||||
|
- First-page thumbnails generated automatically via `pdftoppm` (poppler-utils) if available
|
||||||
|
- Multiple components can share the same PDF
|
||||||
|
- Click any thumbnail to open it full-size in a lightbox
|
||||||
|
|
||||||
|
### Templates (Name Formatters)
|
||||||
|
Write JavaScript formatter functions that generate smart display names for components based on their
|
||||||
|
field values. Example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(c) => {
|
||||||
|
const F = c.fields;
|
||||||
|
if (F.resistance && F.imperial_package) {
|
||||||
|
return `${F.resistance}Ω ${F.imperial_package}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields are accessed by name (e.g. `c.fields.resistance`). If the formatter returns a non-empty
|
||||||
|
string it's used as the display name; otherwise the component's base name is used as fallback.
|
||||||
|
Multiple formatters are tried in order.
|
||||||
|
|
||||||
|
The template editor includes a test data box so you can preview the output without needing real
|
||||||
|
inventory data.
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
A ⚙ menu in the top-right corner provides maintenance operations:
|
||||||
|
|
||||||
|
- **Generate missing PDF thumbnails** — scans all PDFs and generates thumbnails for any that
|
||||||
|
don't have one yet (useful if `pdftoppm` was unavailable at upload time)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js >= 25
|
||||||
|
- npm
|
||||||
|
- `gcc` (to build `tools/mv-sync` — only needed once)
|
||||||
|
- `pdftoppm` from poppler-utils (optional, for PDF thumbnails)
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install directly from the git repository — there is no npm package yet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install git+https://gitea.efforting.tech/mikael-lovqvists-claude-agent/electronics-inventory.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Or clone if you prefer to keep the source around:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.efforting.tech/mikael-lovqvists-claude-agent/electronics-inventory
|
||||||
|
cd electronics-inventory
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build native tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools && make
|
||||||
|
```
|
||||||
|
|
||||||
|
This compiles `mv-sync`, a small helper that performs an atomic rename-without-overwrite using
|
||||||
|
`renameat2(RENAME_NOREPLACE)` (Linux 3.15+). It is required for PDF file operations.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The server starts on port 3020, bound to `localhost` only, by default.
|
||||||
|
Open [http://localhost:3020](http://localhost:3020) in your browser.
|
||||||
|
|
||||||
|
Both can be overridden with environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=8080 npm start # different port
|
||||||
|
BIND_ADDRESS=0.0.0.0 npm start # all interfaces (LAN accessible)
|
||||||
|
BIND_ADDRESS=192.168.1.50 npm start # specific interface
|
||||||
|
PORT=8080 BIND_ADDRESS=0.0.0.0 npm start # both
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** The default `localhost` binding means the app is only reachable from the same machine.
|
||||||
|
> Set `BIND_ADDRESS=0.0.0.0` to expose it on your local network.
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
All data is stored locally in a `data/` directory created automatically on first run:
|
||||||
|
|
||||||
|
- `data/db.json` — component, field, inventory, grid, template, and PDF records (flat KV store)
|
||||||
|
- `data/images/` — uploaded source images and component/inventory photos
|
||||||
|
- `data/pdfs/` — uploaded PDF files and their thumbnails
|
||||||
|
|
||||||
|
No external database is required.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
server.mjs Express 5 API server + SPA host
|
||||||
|
lib/
|
||||||
|
storage.mjs Server-side KV store wrappers
|
||||||
|
kv-store.mjs JSON file-backed key-value store
|
||||||
|
ids.mjs ID generation
|
||||||
|
grid-image.mjs Grid image processing helpers
|
||||||
|
tools/
|
||||||
|
mv-sync.c Atomic rename helper (renameat2 RENAME_NOREPLACE)
|
||||||
|
Makefile
|
||||||
|
public/
|
||||||
|
app.mjs Single-page app (vanilla JS ES modules)
|
||||||
|
templates.html HTML templates (lazy-loaded)
|
||||||
|
style.css Styles
|
||||||
|
lib/
|
||||||
|
api.mjs Fetch wrappers for the REST API
|
||||||
|
dom.mjs DOM helpers
|
||||||
|
views/
|
||||||
|
grid-setup.mjs Canvas-based grid corner editor
|
||||||
|
```
|
||||||
561
future-plans.md
Normal file
561
future-plans.md
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
# Future Plans
|
||||||
|
|
||||||
|
## KV store
|
||||||
|
|
||||||
|
### Extract into shared library
|
||||||
|
`kv-store.mjs` is already copied into at least 3 projects (`electronics-inventory`,
|
||||||
|
`fs-views`, `publication-tool`). Should live in its own Gitea repo as an installable
|
||||||
|
npm package (`npm install git+https://...`) so changes propagate rather than drift.
|
||||||
|
|
||||||
|
### Hierarchical storage structure
|
||||||
|
The current store is a flat string→value map with prefixed keys (`f:`, `c:`, `bin:`,
|
||||||
|
etc.) as a manual namespacing convention. This should be replaced with a proper tree:
|
||||||
|
collections as top-level keys whose values are `Record<id, object>`. Eliminates the
|
||||||
|
prefix convention, makes collection access direct and self-documenting, and removes
|
||||||
|
the full-scan `startsWith` pattern from every `list_*` function. Requires a one-time
|
||||||
|
migration of existing NDJSON data. Best done as part of the shared library rewrite.
|
||||||
|
|
||||||
|
### Delta / revision tracking
|
||||||
|
Add a delta log alongside the main snapshot file (e.g. `inventory.ndjson.deltas`)
|
||||||
|
that records every `set`/`delete` as a timestamped entry. The main file stays a
|
||||||
|
clean current-state snapshot; the delta file accumulates the full history. Enables
|
||||||
|
undo, audit trails, and debugging data corruption.
|
||||||
|
|
||||||
|
## Real-time / live updates
|
||||||
|
|
||||||
|
### Dual event bus architecture
|
||||||
|
|
||||||
|
Two independent buses, bridged by SSE:
|
||||||
|
|
||||||
|
**Server bus** (`lib/bus.mjs`, Node `EventEmitter`):
|
||||||
|
- Route handlers emit a specific event after each mutation (`field:deleted`,
|
||||||
|
`bin:changed`, etc.) and nothing else — side effects are not inline
|
||||||
|
- Effect modules in `server/effects/` subscribe and handle cascading work:
|
||||||
|
- `field-effects.mjs` — strips deleted field from all components/bins/bin types
|
||||||
|
- `sse-effects.mjs` — broadcasts mutations to connected SSE clients
|
||||||
|
- `audit-effects.mjs` — writes delta log (future)
|
||||||
|
- `bin-effects.mjs` — e.g. propagates type dimension changes to bins
|
||||||
|
- New cross-cutting concerns (audit, cache invalidation, notifications) are
|
||||||
|
additional listeners — route handlers never grow
|
||||||
|
|
||||||
|
**Client bus** (`lib/bus.mjs`, lightweight pub/sub or `EventTarget`):
|
||||||
|
- `api.mjs` emits on the bus after every successful mutation
|
||||||
|
- `sse.mjs` (SSE connection client) translates incoming server events to bus emits
|
||||||
|
- View modules subscribe to relevant events and re-render; they never call each other
|
||||||
|
- `mock-api.mjs` also emits on the same bus after in-memory mutations, so views
|
||||||
|
react correctly in mock mode without any SSE
|
||||||
|
|
||||||
|
**SSE bridge**: `sse-effects.mjs` on the server broadcasts to connected clients;
|
||||||
|
`sse.mjs` on the client receives and re-emits on the client bus. Views are unaware
|
||||||
|
of whether a change was local or remote.
|
||||||
|
|
||||||
|
**Avoiding wildcard listeners**: instead of a wildcard `*` listener (not natively
|
||||||
|
supported by `EventEmitter`), emit a generic `mutation` event alongside every
|
||||||
|
specific event. The SSE broadcaster listens to `mutation`; everything else listens
|
||||||
|
to specific events. New event types are automatically forwarded without touching
|
||||||
|
the broadcaster.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function emit(event, data) {
|
||||||
|
bus.emit(event, data);
|
||||||
|
bus.emit('mutation', { event, data });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event granularity**: collection-level events are sufficient (`bins:changed`,
|
||||||
|
`components:changed`). Passing the affected id or record is optional — views can
|
||||||
|
use it to do a targeted update or ignore it and re-fetch the collection. Fine-grained
|
||||||
|
events are an optimisation to add later if full-collection re-fetches become slow.
|
||||||
|
|
||||||
|
Ties into the delta tracking plan: `audit-effects.mjs` is another bus listener —
|
||||||
|
the same mutation path that drives SSE also drives the delta log.
|
||||||
|
|
||||||
|
## App architecture
|
||||||
|
|
||||||
|
### parse_url mutates too many module-level variables
|
||||||
|
`parse_url()` directly assigns to a large number of module-level state variables
|
||||||
|
(`section`, `grid_view_state`, `grid_tab`, `current_grid_id`, `grid_draft`,
|
||||||
|
`current_panel_idx`, `grid_source_id`, `highlight_cell`, `selected_component_id`).
|
||||||
|
This is fragile and hard to reason about.
|
||||||
|
|
||||||
|
Preferred direction: represent the full UI state as a single immutable state object,
|
||||||
|
and have `parse_url()` return a new state value rather than mutating globals:
|
||||||
|
```js
|
||||||
|
function parse_url(path) {
|
||||||
|
return { section, grid_view_state, current_grid_id, ... };
|
||||||
|
}
|
||||||
|
state = parse_url(location.pathname); render(state);
|
||||||
|
```
|
||||||
|
|
||||||
|
### render() if/else chain
|
||||||
|
The render dispatcher is a long chain of bare `else if` branches. Replace with a
|
||||||
|
lookup table:
|
||||||
|
```js
|
||||||
|
const SECTION_RENDERERS = {
|
||||||
|
components: render_components,
|
||||||
|
inventory: render_inventory,
|
||||||
|
fields: render_fields,
|
||||||
|
grids: render_grids,
|
||||||
|
templates: render_templates,
|
||||||
|
};
|
||||||
|
function render() { sync_nav(); SECTION_RENDERERS[section]?.(); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI as a self-contained sub-project
|
||||||
|
|
||||||
|
The UI boundary is `api.mjs` — every piece of data the UI touches goes through
|
||||||
|
named exports in that file. This seam should be made explicit so the UI can be
|
||||||
|
developed and tested against a mock without a running server.
|
||||||
|
|
||||||
|
**Composition root / dependency injection**: `app.mjs` should not import `api.mjs`
|
||||||
|
directly. Instead it receives the api implementation as a parameter. Two thin entry
|
||||||
|
files wire it up:
|
||||||
|
|
||||||
|
```
|
||||||
|
main.mjs — imports real api.mjs, passes to app.start()
|
||||||
|
mock-main.mjs — imports mock-api.mjs, passes to app.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
`mock-main.mjs` is a separate deployable (e.g. served at `/mock` or on a dev port),
|
||||||
|
not a URL flag. The app has no runtime knowledge of which implementation it received.
|
||||||
|
|
||||||
|
**mock-api.mjs**: same exports as `api.mjs`, backed by in-memory arrays seeded with
|
||||||
|
realistic fixture data. Mutations update the in-memory state so the UI behaves
|
||||||
|
realistically (add/delete/edit all persist within the session). Also emits on the
|
||||||
|
client bus so cross-view reactivity works identically to the real app. No SSE
|
||||||
|
connection needed in mock mode — the bus events come from the mock mutations.
|
||||||
|
|
||||||
|
**Views never call each other**: once split into modules, `views/bins.mjs` must
|
||||||
|
not import `views/inventory.mjs`. Cross-section reactions happen exclusively through
|
||||||
|
the client bus. This is the main structural discipline that makes the split work.
|
||||||
|
|
||||||
|
### app.mjs monolith
|
||||||
|
`app.mjs` is large. Split into per-section view modules (`views/components.mjs`,
|
||||||
|
`views/grids.mjs`, `views/bins.mjs`, etc.) each owning its local state, subscribing
|
||||||
|
to bus events at init, and exporting a single `mount(container)` function. The
|
||||||
|
composition root (`main.mjs`) imports all view modules and registers them.
|
||||||
|
|
||||||
|
### Split CSS into per-section files
|
||||||
|
`style.css` is a single large file and getting hard to navigate. Split into
|
||||||
|
per-section files (`components.css`, `grids.css`, `bins.css`, etc.) plus a
|
||||||
|
`base.css` for variables, resets, and shared layout. A `make build` step can
|
||||||
|
concatenate them into a single `style.css` for deployment, keeping the dev
|
||||||
|
experience clean without adding a bundler dependency.
|
||||||
|
|
||||||
|
### Explicit save in component editor
|
||||||
|
Currently any change in the component detail panel (linking a file, unlinking an
|
||||||
|
inventory entry, etc.) is persisted immediately. This makes it hard to experiment or
|
||||||
|
undo. The component editor dialog should have an explicit Save button and hold all
|
||||||
|
changes locally until confirmed. Relates to the broader question of whether live
|
||||||
|
mutations elsewhere in the UI should be deferred similarly.
|
||||||
|
|
||||||
|
### DRY / SSoT audit
|
||||||
|
As the app grows, patterns are being duplicated rather than centralized. Areas to
|
||||||
|
review:
|
||||||
|
- Field sorting: same sort-by-name logic appears in both detail view and edit dialog
|
||||||
|
- Field rendering: `render_field_value()` exists but call sites still sometimes
|
||||||
|
inline display logic
|
||||||
|
- Component display name: `component_display_name()` is the SSoT but there may be
|
||||||
|
call sites that still use `c.name` directly
|
||||||
|
- Server-side: PDF conflict checks, sanitize calls, and rename logic are inline in
|
||||||
|
route handlers — could be extracted into a `pdf_service` helper
|
||||||
|
- General pass to identify and eliminate copy-paste between routes and between
|
||||||
|
render functions before the codebase grows further
|
||||||
|
|
||||||
|
## Field system
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
#### Component IDs in dropdowns and lists
|
||||||
|
The component selector dropdown (e.g. in the inventory entry dialog) only shows the
|
||||||
|
display name, which is ambiguous when multiple components share a name. Should also
|
||||||
|
show the component ID.
|
||||||
|
|
||||||
|
#### Migrate to integer IDs
|
||||||
|
Current IDs are timestamp-base36 + random chars. Replace with plain integers
|
||||||
|
(auto-incrementing). Benefits: human-readable, shorter in URLs, sortable by creation
|
||||||
|
order, easier to reference verbally.
|
||||||
|
|
||||||
|
Migration must be done as an explicit standalone tool (`tools/migrate-ids.mjs` or
|
||||||
|
similar) that:
|
||||||
|
1. Reads the current database
|
||||||
|
2. Builds an old→new ID mapping for all entity types (components, fields, inventory
|
||||||
|
entries, grids, PDFs, etc.)
|
||||||
|
3. Rewrites all references throughout the data (e.g. inventory entries reference
|
||||||
|
component IDs, components reference field IDs, file_ids arrays, etc.)
|
||||||
|
4. Writes a new database file without touching the original until explicitly
|
||||||
|
confirmed
|
||||||
|
5. Keeps a mapping log so the migration is auditable and reversible
|
||||||
|
|
||||||
|
Should not be run automatically — operator invokes it deliberately after backing up.
|
||||||
|
|
||||||
|
#### Component list sorted by display name
|
||||||
|
The component left pane list is currently sorted by base name. It should sort by
|
||||||
|
display name (i.e. the formatter output) so the list order matches what the user
|
||||||
|
actually sees as the component label.
|
||||||
|
|
||||||
|
#### Field display in component detail should use a table
|
||||||
|
Currently rendered as CSS grid rows but columns don't align because each row is
|
||||||
|
independent. Use an actual `<table>` so name and value columns line up across all
|
||||||
|
fields. This is tabular data and a table is the right element.
|
||||||
|
|
||||||
|
#### Field value parser chain
|
||||||
|
Similar to how name formatters use a template chain, field values could be passed
|
||||||
|
through a parser chain that returns structured data based on field name/type hints.
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- A field whose name contains `tolerance` could parse `20` as `{ negative: 20, positive: 20 }`
|
||||||
|
and `-10/+30` as `{ negative: 10, positive: 30 }`
|
||||||
|
- URL detection (currently hardcoded in `render_field_value()`) could be one parser
|
||||||
|
in this chain rather than a special case
|
||||||
|
- Mouser/Digi-Key part numbers could be detected and return a structured link target
|
||||||
|
|
||||||
|
The parser chain would mirror the template system: user-defined or built-in parsers
|
||||||
|
keyed by field name pattern, tried in order, returning structured data or `null` to
|
||||||
|
pass through to the next. `render_field_value()` would then receive parsed data and
|
||||||
|
render accordingly.
|
||||||
|
|
||||||
|
#### Field rendering integrations
|
||||||
|
With or without a parser chain, `render_field_value()` should gain:
|
||||||
|
- Mouser/Digi-Key part number fields → auto-craft links to product pages
|
||||||
|
- More URL-like patterns (without `https://` prefix)
|
||||||
|
|
||||||
|
#### Field selector filter
|
||||||
|
When adding a field to a component in the edit dialog, the dropdown becomes
|
||||||
|
unwieldy with many fields. Add a filter/search input to the field selector.
|
||||||
|
|
||||||
|
#### Custom field input modes
|
||||||
|
Fields could support multiple named input modes that accept different notations and
|
||||||
|
convert to the canonical stored value. Example for resistance:
|
||||||
|
- `direct` — enter `100k` or `4k7` directly
|
||||||
|
- `3-digit SMD` — enter `104` (decoded as 10×10⁴ = 100kΩ)
|
||||||
|
- `4-digit SMD` — enter `1003` (decoded as 100×10³ = 100kΩ)
|
||||||
|
|
||||||
|
The active input mode is selected via a small dropdown next to the field input, with
|
||||||
|
a keyboard shortcut to cycle through modes quickly. The last used mode per field is
|
||||||
|
remembered. Input modes are associated with the field definition (or the measurement
|
||||||
|
dimension), not per-component. Ties in with the measurement/dimension system —
|
||||||
|
modes are really just different parsers that produce the same canonical value.
|
||||||
|
|
||||||
|
#### Keyboard shortcut for adding a field
|
||||||
|
When filling out many fields on a component, repeatedly reaching for the mouse to
|
||||||
|
hit "add field" is slow. Add a configurable keyboard shortcut (e.g. Alt+F) to
|
||||||
|
focus/trigger the add-field selector from anywhere in the component editor.
|
||||||
|
|
||||||
|
#### Search matches field names
|
||||||
|
The current word-split search only matches field values, not field names. Should
|
||||||
|
also match on field names so searching `dielectric_characteristics` finds all
|
||||||
|
components that have that field set, regardless of its value.
|
||||||
|
|
||||||
|
#### Parametric search
|
||||||
|
Allow searching/filtering components by field values, not just names. Examples:
|
||||||
|
- `resistance < 10k`, `package = 0603`, `voltage_rating >= 50`
|
||||||
|
- Cross-field queries: find all 0603 resistors under 10kΩ
|
||||||
|
- Should integrate with the existing word-split search or replace it with a
|
||||||
|
richer query language
|
||||||
|
- Depends on field types (numeric vs string) for range queries to work correctly
|
||||||
|
|
||||||
|
### Long term
|
||||||
|
|
||||||
|
#### Field grouping / linkage
|
||||||
|
Some fields naturally belong together (e.g. `frequency_stability` and
|
||||||
|
`frequency_stability_temp_range`). Options:
|
||||||
|
- Soft linkage: tag fields with a group name, display grouped in the UI
|
||||||
|
- Structured fields: a field can be a record type with named sub-fields
|
||||||
|
(e.g. `stability: { value: 10, unit: "ppm", temp_low: -40, temp_high: 85 }`)
|
||||||
|
|
||||||
|
Structured records are the more powerful option but require a schema system and
|
||||||
|
more complex UI. Grouping/linkage is a lighter short-term win.
|
||||||
|
|
||||||
|
As fields are shared across entity types (components, bins, bin types, and anything
|
||||||
|
else added later), the field pool grows to span unrelated domains. Groups also serve
|
||||||
|
as a domain filter in the field selector — when adding a field to a bin type, you
|
||||||
|
should be able to filter to e.g. "physical" or "storage" fields rather than seeing
|
||||||
|
electrical component fields mixed in. Each field should be able to belong to one or
|
||||||
|
more groups.
|
||||||
|
|
||||||
|
#### Semantically-aware formatting (acronyms, proper names)
|
||||||
|
Formatters that apply title case or similar text transformations can corrupt acronyms
|
||||||
|
(e.g. `NPN` → `Npn`) or brand/proper names. The root cause is that free-text field
|
||||||
|
values carry no semantic metadata about what kind of string they are. A long-term
|
||||||
|
fix requires fields to be semantically rich enough that formatters know whether a
|
||||||
|
value is an acronym, brand name, common noun, number, etc., and apply appropriate
|
||||||
|
rules per token. Relates to field types and structured field value work.
|
||||||
|
|
||||||
|
#### Renderer/parser result cache
|
||||||
|
Once parsers and formatters run per-render, a cache keyed on field value + template
|
||||||
|
version would avoid redundant work on large inventories. Invalidated when any
|
||||||
|
template changes. Not urgent — premature until the parser chain exists.
|
||||||
|
|
||||||
|
#### Field types
|
||||||
|
Currently all field values are free-text strings. Typed fields (numeric,
|
||||||
|
enum/dropdown) would enable better formatting, validation, and range-aware search.
|
||||||
|
Prerequisite for parametric search with range operators.
|
||||||
|
|
||||||
|
#### Measurement dimensions and unit conversion
|
||||||
|
Instead of a bare unit string on a field, associate a field with a measurement
|
||||||
|
dimension (e.g. `temperature`, `resistance`, `frequency`, `voltage`). The dimension
|
||||||
|
defines the set of valid units and the conversion factors between them (°C, °K, °R,
|
||||||
|
°F for temperature; Ω, kΩ, MΩ for resistance; etc.).
|
||||||
|
|
||||||
|
SI prefixes (k, M, µ, n, p, etc.) are not separate units — they are a presentation
|
||||||
|
layer on top of a unit. `25kΩ` should be stored as
|
||||||
|
`{ value: "25", prefix: "k", unit: "Ω" }` — preserving the original string value
|
||||||
|
and prefix exactly as entered, so no precision or notation is lost.
|
||||||
|
|
||||||
|
A canonical numeric form is derived from the stored triple only when needed for
|
||||||
|
comparison or search queries (e.g. `R < 10k` → compare canonical floats). Display
|
||||||
|
always reconstructs from the stored `value + prefix + unit`, so `4k7` stays `4k7`
|
||||||
|
and `25.0` stays `25.0`.
|
||||||
|
|
||||||
|
This would allow:
|
||||||
|
- Lossless storage of entered values (significant digits, notation style preserved)
|
||||||
|
- Parametric search with cross-prefix comparisons via derived canonical values
|
||||||
|
- Unit conversion on query (e.g. `temp > 200K` matching a stored `-73°C`)
|
||||||
|
- Catching unit mismatches at entry time
|
||||||
|
|
||||||
|
## Multi-user and access control
|
||||||
|
|
||||||
|
### Multi-user support
|
||||||
|
Currently single-user with no authentication. For shared/team use:
|
||||||
|
- User accounts with login (session or token-based)
|
||||||
|
- Per-user audit trail (who added/changed what, ties into delta tracking)
|
||||||
|
- Optional: user-specific preferences (display units, default grid, etc.)
|
||||||
|
|
||||||
|
### Team / permission model
|
||||||
|
Teams or roles controlling what users can do:
|
||||||
|
- Read-only members (view inventory, no edits)
|
||||||
|
- Contributors (add/edit components and inventory)
|
||||||
|
- Admins (manage fields, grids, users)
|
||||||
|
- Possible per-resource permissions (e.g. a team owns a specific grid)
|
||||||
|
|
||||||
|
### Common user/team library
|
||||||
|
User and team management is a recurring need across projects. Should be extracted
|
||||||
|
into a shared library (alongside the planned kv-store library) rather than
|
||||||
|
reimplemented per project. The library would provide:
|
||||||
|
- User CRUD with hashed credentials
|
||||||
|
- Session/token management
|
||||||
|
- Role and permission primitives
|
||||||
|
- Middleware for Express (protect routes by role)
|
||||||
|
|
||||||
|
The electronics inventory would then depend on this library rather than rolling its
|
||||||
|
own auth. Other projects (`publication-tool`, future apps) would do the same.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Read-only public mode
|
||||||
|
A runtime flag (e.g. `READ_ONLY=1`) that starts the server in a read-only mode
|
||||||
|
suitable for public-facing deployment:
|
||||||
|
- All write API endpoints disabled (POST/PUT/DELETE return 403)
|
||||||
|
- UI hides all edit controls, dialogs, and maintenance actions
|
||||||
|
- Data served directly from the same `data/` directory
|
||||||
|
|
||||||
|
This allows a simple deployment workflow: rsync the `data/` directory from the
|
||||||
|
private instance to a public server running in read-only mode. No database sync,
|
||||||
|
no separate export step.
|
||||||
|
|
||||||
|
## Editor
|
||||||
|
|
||||||
|
### Use CodeMirror 6 for JavaScript input fields
|
||||||
|
Any field that accepts JavaScript (name formatter templates, future custom search
|
||||||
|
views, field parsers, etc.) should use a CodeMirror 6 editor instead of a plain
|
||||||
|
`<textarea>`. Gives syntax highlighting, bracket matching, and a proper editing
|
||||||
|
experience for JS snippets.
|
||||||
|
|
||||||
|
## Template system
|
||||||
|
|
||||||
|
### Unified formatter → template pipeline and terminology revision
|
||||||
|
The current system conflates several distinct concepts under the word "template",
|
||||||
|
creating ambiguity:
|
||||||
|
|
||||||
|
- The HTML `<template>` elements used for UI cloning (internal, not user-facing)
|
||||||
|
- The user-written JS formatter functions (currently called "templates" in the UI)
|
||||||
|
- The future idea of user-defined DOM rendering templates
|
||||||
|
|
||||||
|
Proposed clearer terminology:
|
||||||
|
- **Formatter** — a user-written JS function that receives a component and returns a
|
||||||
|
structured record (named slots), e.g. `{ label, sublabel, badge, ... }`
|
||||||
|
- **Renderer** — a DOM fragment template (possibly user-defined) that consumes a
|
||||||
|
formatter's record and produces the visual output for a given context (list row,
|
||||||
|
detail header, dropdown item, etc.)
|
||||||
|
- **View template** — the internal HTML `<template>` cloning mechanism (keep as-is,
|
||||||
|
but don't expose this term to users)
|
||||||
|
|
||||||
|
The pipeline becomes: `component → formatter → record → renderer → DOM`. Formatters
|
||||||
|
and renderers are decoupled — the same formatter record can feed different renderers
|
||||||
|
in different contexts. Users can define custom renderers (DOM fragments with named
|
||||||
|
slot targets) in addition to custom formatters.
|
||||||
|
|
||||||
|
This revision also applies to field parsers and search view expressions once those
|
||||||
|
exist — they all follow the same pattern of JS function → structured output →
|
||||||
|
context-specific renderer.
|
||||||
|
|
||||||
|
## Search & views
|
||||||
|
|
||||||
|
### Custom search views
|
||||||
|
Saved searches defined as JS expressions (similar to the template system), evaluated
|
||||||
|
against each component to produce a filtered and optionally transformed list. Example
|
||||||
|
use cases:
|
||||||
|
- "all components with a non-empty `todo` field"
|
||||||
|
- "all SMD resistors with no datasheet attached"
|
||||||
|
- "all components missing a `package` field"
|
||||||
|
|
||||||
|
Views would be named, saved, and accessible from the nav or a dedicated views
|
||||||
|
section. The expression receives the full component object and returns truthy to
|
||||||
|
include it. Could later be extended to also control sort order and displayed columns.
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
### Image gallery / browser
|
||||||
|
The current image upload for components is minimal. Replace with a proper image
|
||||||
|
gallery dialog (mirroring the PDF file picker) that shows all uploaded images with
|
||||||
|
thumbnails and supports:
|
||||||
|
- File input upload (existing)
|
||||||
|
- Drag and drop onto the gallery area
|
||||||
|
- Clipboard paste (Ctrl+V — useful for pasting screenshots directly)
|
||||||
|
- URL entry (fetch and store server-side)
|
||||||
|
|
||||||
|
Images should be manageable from the gallery: rename, delete, link/unlink from
|
||||||
|
component, open full-size in lightbox. Like the PDF picker, the gallery should be
|
||||||
|
reusable across components (an image can be shared between components).
|
||||||
|
|
||||||
|
## PDF / files
|
||||||
|
|
||||||
|
### Auto-select file after upload in file picker
|
||||||
|
When uploading a PDF from within the file picker (opened from a component), the
|
||||||
|
newly uploaded file should be automatically linked to that component without
|
||||||
|
requiring a manual "Select" click.
|
||||||
|
|
||||||
|
### File picker search filter
|
||||||
|
The file picker dialog has no search/filter input. With many datasheets this becomes
|
||||||
|
unwieldy. Add a filter input at the top of the list that narrows by display name and
|
||||||
|
filename.
|
||||||
|
|
||||||
|
### PDF page count and multi-page navigation
|
||||||
|
Currently only the first page thumbnail is shown. Could show page count and allow
|
||||||
|
browsing pages in the lightbox.
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
### Bins as a storage item type
|
||||||
|
Support bins (physical containers, boxes, bags, reels, etc.) as inventory items in
|
||||||
|
their own right — not just as locations. A bin can hold components but is itself a
|
||||||
|
trackable thing. Bins may contain non-electronic items.
|
||||||
|
|
||||||
|
### Inventory type-specific views
|
||||||
|
Currently the inventory and components views are tightly coupled to the assumption
|
||||||
|
that everything is an electronic component. Long term, the system should support
|
||||||
|
multiple item types (components, bins, tools, materials, etc.) with:
|
||||||
|
- A generic "everything" view showing all inventory regardless of type
|
||||||
|
- Type-specific views (e.g. the current components view) that filter and present
|
||||||
|
items with type-relevant fields and UI
|
||||||
|
- The current components section becomes one such type-specific view rather than
|
||||||
|
the only view
|
||||||
|
|
||||||
|
**Implementation approach:** Add a `type` field to items (e.g. `component`, `bin`,
|
||||||
|
`tool`). Type-specific views are just filtered views over all items. No separate
|
||||||
|
collection or schema per type — the type field drives which view renders it.
|
||||||
|
|
||||||
|
**Migration:** Bulk assignment via the existing field system — e.g. set `type =
|
||||||
|
"component"` on all current items in one operation, since they're all components.
|
||||||
|
No per-item manual work needed.
|
||||||
|
|
||||||
|
### Inventory URL reflects selected entry
|
||||||
|
Similar to how components now reflect `/components/:id` in the URL, inventory
|
||||||
|
entries have no URL state — refreshing loses context.
|
||||||
|
|
||||||
|
### Recent locations in inventory entry dialog
|
||||||
|
When picking a storage location for a component, show a list of recently
|
||||||
|
used/visited locations at the top so you can quickly re-select where you just were.
|
||||||
|
Useful when processing a batch of components into the same storage location — you
|
||||||
|
shouldn't have to navigate the grid picker from scratch each time.
|
||||||
|
|
||||||
|
## Bins
|
||||||
|
|
||||||
|
### Bin types
|
||||||
|
Define reusable bin type records (e.g. "Sortimo L-Boxx insert small", "Wago 221
|
||||||
|
connector box") that store physical dimensions (mm), and optionally a default
|
||||||
|
compartment layout. When creating or editing a bin, the user picks a type and the
|
||||||
|
dimensions are pre-filled — no need to re-enter for every bin of the same model.
|
||||||
|
This also enables filtering/grouping bins by type, and makes it easy to re-process
|
||||||
|
all bins of a type if the corner algorithm improves.
|
||||||
|
|
||||||
|
### Generic fields on bins and bin types
|
||||||
|
Bins and bin types should both support the same generic field system as components —
|
||||||
|
arbitrary key/value pairs from the shared field definitions. Examples: color, material,
|
||||||
|
manufacturer, max load, purchase link. Bin types carry the "template" fields (e.g.
|
||||||
|
nominal dimensions from the datasheet) while individual bins carry instance-specific
|
||||||
|
fields (e.g. actual color of that specific unit).
|
||||||
|
|
||||||
|
Because fields are shared across components, bins, and anything else that grows into
|
||||||
|
the system, they will quickly span unrelated domains. Field grouping (see Field system
|
||||||
|
section) becomes important here so the field selector can be filtered to show only
|
||||||
|
relevant fields for the current entity type.
|
||||||
|
|
||||||
|
### Duplicate any entity
|
||||||
|
All objects in the system should be duplicatable: components, bin types, bins, grids,
|
||||||
|
templates, inventory entries, and eventually source images. The duplicate operation
|
||||||
|
creates a new record with all fields copied, then opens it in an edit dialog so the
|
||||||
|
user can adjust what differs. Bin type duplication is especially common — same
|
||||||
|
physical container model in different colors or configurations. Source images are a
|
||||||
|
later case since they reference uploaded files; duplication there would mean creating
|
||||||
|
a new metadata record pointing to the same underlying file (or an explicit copy).
|
||||||
|
|
||||||
|
## Grids
|
||||||
|
|
||||||
|
### Grid view layers
|
||||||
|
Allow a storage grid to reference one or more separate "view layer" grids that share
|
||||||
|
the same logical layout but use different source images. Example: the storage grid
|
||||||
|
uses close-up photos of individual cells for identification, while a view layer uses
|
||||||
|
a wider photo of the lid or top side for orientation.
|
||||||
|
|
||||||
|
Key design points:
|
||||||
|
- Grids get a classification: `storage` (can hold inventory) or `view` (display
|
||||||
|
only, referenced by storage grids)
|
||||||
|
- View layers may use a different panel sub-grid layout (fewer, larger panels) as
|
||||||
|
long as the final logical row×col count matches the storage grid
|
||||||
|
- In the grid viewer, layers can be toggled to switch between the storage view and
|
||||||
|
any attached view layers
|
||||||
|
- A storage grid can have multiple view layers (e.g. lid photo, tray photo, labeled
|
||||||
|
overlay)
|
||||||
|
|
||||||
|
### Irregular grid layouts and merged cells
|
||||||
|
Real storage boxes rarely have perfectly uniform grids. Two distinct physical
|
||||||
|
configurations need to be supported:
|
||||||
|
|
||||||
|
**Type A — uniform grid with merged cells:** A regular N×M grid where some adjacent
|
||||||
|
cells are physically merged into one larger cell (always an integer multiple of the
|
||||||
|
base cell size). Common in component assortment boxes. A merged cell is both a
|
||||||
|
physical and logical unit — you store one thing in it.
|
||||||
|
|
||||||
|
**Type B — stacked sub-grids:** A container where each row (or section) has a
|
||||||
|
different column count and cell size. Example: 5 rows of 5 small columns, then 1
|
||||||
|
row of 4 medium columns, then 1 row with a single large drawer. Cells are not
|
||||||
|
multiples of a common base — the sections are structurally independent.
|
||||||
|
|
||||||
|
**Logical merging (cell groups):** Independent of physical layout, a user should be
|
||||||
|
able to group several cells into a single named logical location. The motivating
|
||||||
|
case is a batch of 50 components that won't fit in one cell — they spill across 3
|
||||||
|
cells, but you want one inventory entry saying "these cells together hold this
|
||||||
|
batch", not three separate entries to keep in sync. This is purely a
|
||||||
|
storage/inventory concern, not a grid layout concern.
|
||||||
|
|
||||||
|
**Open question — architecture:** Should this be:
|
||||||
|
1. A single generic nested/hierarchical grid model flexible enough to encode both
|
||||||
|
types (more complex but unified), or
|
||||||
|
2. Two explicit grid styles (`uniform+merges` and `stacked-sections`) that cover
|
||||||
|
the common cases without a fully general solution?
|
||||||
|
|
||||||
|
Option 2 is likely sufficient for real-world boxes and much easier to implement and
|
||||||
|
display. Worth prototyping before committing to a generic model.
|
||||||
|
|
||||||
|
### Multi-cell grid storage selection
|
||||||
|
A component stored in a grid should be able to span multiple cells, since larger
|
||||||
|
parts often occupy more than one cell. The graphical cell picker in the inventory
|
||||||
|
dialog should support selecting a range or set of cells rather than a single cell.
|
||||||
|
The grid viewer should reflect multi-cell occupancy in its count badges and cell
|
||||||
|
highlighting.
|
||||||
|
|
||||||
|
### Grid URL state
|
||||||
|
Navigating into a grid viewer updates the URL correctly, but the grid list and
|
||||||
|
draft state have no URL representation.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -125,6 +125,99 @@ export function delete_source_image(id) {
|
|||||||
return store.delete(`s:${id}`);
|
return store.delete(`s:${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Component templates ---
|
||||||
|
|
||||||
|
export function list_component_templates() {
|
||||||
|
const result = [];
|
||||||
|
for (const [key] of store.data.entries()) {
|
||||||
|
if (key.startsWith('ct:')) result.push(store.get(key));
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_component_template(id) {
|
||||||
|
return store.get(`ct:${id}`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set_component_template(tmpl) {
|
||||||
|
store.set(`ct:${tmpl.id}`, tmpl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delete_component_template(id) {
|
||||||
|
return store.delete(`ct:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PDF files ---
|
||||||
|
|
||||||
|
export function list_pdfs() {
|
||||||
|
const result = [];
|
||||||
|
for (const [key] of store.data.entries()) {
|
||||||
|
if (key.startsWith('pdf:')) result.push(store.get(key));
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_pdf(id) {
|
||||||
|
return store.get(`pdf:${id}`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set_pdf(pdf) {
|
||||||
|
store.set(`pdf:${pdf.id}`, pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delete_pdf(id) {
|
||||||
|
return store.delete(`pdf:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns all components that reference the given PDF id in their file_ids array.
|
||||||
|
export function find_pdf_references(pdf_id) {
|
||||||
|
return list_components().filter(c => c.file_ids?.includes(pdf_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bin types ---
|
||||||
|
|
||||||
|
export function list_bin_types() {
|
||||||
|
const result = [];
|
||||||
|
for (const [key] of store.data.entries()) {
|
||||||
|
if (key.startsWith('bt:')) result.push(store.get(key));
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_bin_type(id) {
|
||||||
|
return store.get(`bt:${id}`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set_bin_type(bt) {
|
||||||
|
store.set(`bt:${bt.id}`, bt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delete_bin_type(id) {
|
||||||
|
return store.delete(`bt:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bins ---
|
||||||
|
|
||||||
|
export function list_bins() {
|
||||||
|
const result = [];
|
||||||
|
for (const [key] of store.data.entries()) {
|
||||||
|
if (key.startsWith('bin:')) result.push(store.get(key));
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_bin(id) {
|
||||||
|
return store.get(`bin:${id}`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set_bin(bin) {
|
||||||
|
store.set(`bin:${bin.id}`, bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delete_bin(id) {
|
||||||
|
return store.delete(`bin:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Grid images ---
|
// --- Grid images ---
|
||||||
|
|
||||||
export function list_grid_images() {
|
export function list_grid_images() {
|
||||||
|
|||||||
829
package-lock.json
generated
829
package-lock.json
generated
@@ -1,829 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "electronics-inventory",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "electronics-inventory",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^5.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=25"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"negotiator": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
|
||||||
"version": "2.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
|
||||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "^3.1.2",
|
|
||||||
"content-type": "^1.0.5",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"http-errors": "^2.0.0",
|
|
||||||
"iconv-lite": "^0.7.0",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"qs": "^6.14.1",
|
|
||||||
"raw-body": "^3.0.1",
|
|
||||||
"type-is": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bytes": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bind-apply-helpers": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bound": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"get-intrinsic": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-type": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie-signature": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/depd": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ee-first": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/encodeurl": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-define-property": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-errors": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-object-atoms": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-html": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/etag": {
|
|
||||||
"version": "1.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "^2.0.0",
|
|
||||||
"body-parser": "^2.2.1",
|
|
||||||
"content-disposition": "^1.0.0",
|
|
||||||
"content-type": "^1.0.5",
|
|
||||||
"cookie": "^0.7.1",
|
|
||||||
"cookie-signature": "^1.2.1",
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"finalhandler": "^2.1.0",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.0",
|
|
||||||
"merge-descriptors": "^2.0.0",
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"once": "^1.4.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"proxy-addr": "^2.0.7",
|
|
||||||
"qs": "^6.14.0",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"router": "^2.2.0",
|
|
||||||
"send": "^1.1.0",
|
|
||||||
"serve-static": "^2.2.0",
|
|
||||||
"statuses": "^2.0.1",
|
|
||||||
"type-is": "^2.0.1",
|
|
||||||
"vary": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"statuses": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/forwarded": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fresh": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-intrinsic": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"es-object-atoms": "^1.1.1",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-symbols": "^1.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"math-intrinsics": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dunder-proto": "^1.0.1",
|
|
||||||
"es-object-atoms": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gopd": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-symbols": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hasown": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-errors": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"depd": "~2.0.0",
|
|
||||||
"inherits": "~2.0.4",
|
|
||||||
"setprototypeof": "~1.2.0",
|
|
||||||
"statuses": "~2.0.2",
|
|
||||||
"toidentifier": "~1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-promise": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/media-typer": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/merge-descriptors": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
|
||||||
"version": "1.54.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-types": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "^1.54.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-inspect": {
|
|
||||||
"version": "1.13.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/on-finished": {
|
|
||||||
"version": "2.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
|
||||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ee-first": "1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/once": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-to-regexp": {
|
|
||||||
"version": "8.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/proxy-addr": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"forwarded": "0.2.0",
|
|
||||||
"ipaddr.js": "1.9.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qs": {
|
|
||||||
"version": "6.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
|
||||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/range-parser": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/raw-body": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.7.0",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/router": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"is-promise": "^4.0.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"path-to-regexp": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/send": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"mime-types": "^3.0.2",
|
|
||||||
"ms": "^2.1.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"statuses": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
|
||||||
"version": "2.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
|
||||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"send": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/setprototypeof": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/side-channel": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-list": "^1.0.0",
|
|
||||||
"side-channel-map": "^1.0.1",
|
|
||||||
"side-channel-weakmap": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-list": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-map": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-weakmap": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-map": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/statuses": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/toidentifier": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"content-type": "^1.0.5",
|
|
||||||
"media-typer": "^1.1.0",
|
|
||||||
"mime-types": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unpipe": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrappy": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1467
public/app.mjs
1467
public/app.mjs
File diff suppressed because it is too large
Load Diff
@@ -15,9 +15,22 @@
|
|||||||
<button class="nav-btn" data-section="inventory">Inventory</button>
|
<button class="nav-btn" data-section="inventory">Inventory</button>
|
||||||
<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="bins">Bins</button>
|
||||||
|
<button class="nav-btn" data-section="images">Images</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="maint-menu" id="maint-menu">
|
||||||
|
<button class="maint-toggle" id="maint-toggle" title="Maintenance">⚙</button>
|
||||||
|
<div class="maint-dropdown" id="maint-dropdown" hidden>
|
||||||
|
<button class="maint-item" id="maint-gen-thumbs">Generate missing PDF thumbnails</button>
|
||||||
|
<button class="maint-item" id="maint-purge-sources">Remove orphaned source image entries</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
|
<div id="lightbox" hidden>
|
||||||
|
<img id="lightbox-img" alt="">
|
||||||
|
</div>
|
||||||
<script type="module" src="/app.mjs"></script>
|
<script type="module" src="/app.mjs"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -35,8 +35,62 @@ export const update_grid_draft = (id, body) => req('PUT', `/api/grid-drafts/${i
|
|||||||
export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`);
|
export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`);
|
||||||
|
|
||||||
// Source images
|
// Source images
|
||||||
export const get_source_images = () => req('GET', '/api/source-images');
|
export const get_source_images = () => req('GET', '/api/source-images');
|
||||||
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
|
export const update_source_image_uses = (id, uses) => req('PUT', `/api/source-images/${id}`, { uses });
|
||||||
|
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
|
||||||
|
|
||||||
|
// Component templates
|
||||||
|
export const get_component_templates = () => req('GET', '/api/component-templates');
|
||||||
|
export const create_component_template = (body) => req('POST', '/api/component-templates', body);
|
||||||
|
export const update_component_template = (id, body) => req('PUT', `/api/component-templates/${id}`, body);
|
||||||
|
export const delete_component_template = (id) => req('DELETE', `/api/component-templates/${id}`);
|
||||||
|
|
||||||
|
// PDF files
|
||||||
|
export const get_pdfs = () => req('GET', '/api/pdfs');
|
||||||
|
export const rename_pdf = (id, display_name, filename) => req('PUT', `/api/pdfs/${id}`, { display_name, filename });
|
||||||
|
export const delete_pdf = (id) => req('DELETE', `/api/pdfs/${id}`);
|
||||||
|
|
||||||
|
export async function upload_pdf(file, display_name, filename) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
if (display_name) form.append('display_name', display_name);
|
||||||
|
if (filename) form.append('filename', filename);
|
||||||
|
const res = await fetch('/api/pdfs', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bin types
|
||||||
|
export const get_bin_types = () => req('GET', '/api/bin-types');
|
||||||
|
export const create_bin_type = (body) => req('POST', '/api/bin-types', body);
|
||||||
|
export const update_bin_type = (id, body) => req('PUT', `/api/bin-types/${id}`, body);
|
||||||
|
export const delete_bin_type = (id) => req('DELETE', `/api/bin-types/${id}`);
|
||||||
|
|
||||||
|
// Bins
|
||||||
|
export const get_bins = () => req('GET', '/api/bins');
|
||||||
|
export const create_bin_from_source = (source_id, name) => req('POST', '/api/bins/from-source', { source_id, name });
|
||||||
|
export const get_bin = (id) => req('GET', `/api/bins/${id}`);
|
||||||
|
export const update_bin = (id, body) => req('PUT', `/api/bins/${id}`, body);
|
||||||
|
export const update_bin_corners = (id, corners, phys_w, phys_h) => req('PUT', `/api/bins/${id}/corners`, { corners, phys_w, phys_h });
|
||||||
|
export const add_bin_content = (id, body) => req('POST', `/api/bins/${id}/contents`, body);
|
||||||
|
export const update_bin_content = (id, cid, body) => req('PUT', `/api/bins/${id}/contents/${cid}`, body);
|
||||||
|
export const delete_bin_content = (id, cid) => req('DELETE', `/api/bins/${id}/contents/${cid}`);
|
||||||
|
export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`);
|
||||||
|
|
||||||
|
export async function upload_bin(file, name) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image', file);
|
||||||
|
if (name) form.append('name', name);
|
||||||
|
const res = await fetch('/api/bins', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
export const maintenance_pdf_thumbs = () => req('POST', '/api/maintenance/pdf-thumbs');
|
||||||
|
export const maintenance_purge_missing_sources = () => req('POST', '/api/maintenance/purge-missing-sources');
|
||||||
|
|
||||||
// Grid images
|
// Grid images
|
||||||
export const get_grids = () => req('GET', '/api/grid-images');
|
export const get_grids = () => req('GET', '/api/grid-images');
|
||||||
|
|||||||
815
public/style.css
815
public/style.css
@@ -32,6 +32,14 @@
|
|||||||
--font-mono: 'Fira Code', 'Cascadia Code', monospace;
|
--font-mono: 'Fira Code', 'Cascadia Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -41,7 +49,6 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== HEADER ===== */
|
/* ===== HEADER ===== */
|
||||||
@@ -93,22 +100,116 @@ nav {
|
|||||||
background: rgba(91, 156, 246, 0.12);
|
background: rgba(91, 156, 246, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== MAINTENANCE MENU ===== */
|
||||||
|
|
||||||
|
.maint-menu {
|
||||||
|
margin-left: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maint-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maint-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maint-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
min-width: 240px;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maint-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maint-item:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== LIGHTBOX ===== */
|
||||||
|
|
||||||
|
#lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lightbox[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lightbox-img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== MAIN ===== */
|
/* ===== MAIN ===== */
|
||||||
|
|
||||||
#main {
|
#main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== SPLIT LAYOUT ===== */
|
/* ===== SPLIT LAYOUT ===== */
|
||||||
|
|
||||||
.split-layout {
|
.split-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-resizer {
|
||||||
|
width: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-resizer:hover,
|
||||||
|
.split-resizer.dragging {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.list-pane {
|
.list-pane {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -119,6 +220,7 @@ nav {
|
|||||||
top: calc(3rem + 1.5rem); /* header + main padding */
|
top: calc(3rem + 1.5rem); /* header + main padding */
|
||||||
max-height: calc(100vh - 3rem - 3rem);
|
max-height: calc(100vh - 3rem - 3rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
margin-right: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-add-input {
|
.quick-add-input {
|
||||||
@@ -156,6 +258,7 @@ nav {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
margin-left: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== DETAIL PANEL ===== */
|
/* ===== DETAIL PANEL ===== */
|
||||||
@@ -220,7 +323,7 @@ nav {
|
|||||||
|
|
||||||
.detail-field-row {
|
.detail-field-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 10rem 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
@@ -230,11 +333,16 @@ nav {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-field-value {
|
.detail-field-value {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-empty-note {
|
.detail-empty-note {
|
||||||
@@ -271,7 +379,6 @@ nav {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
display: block;
|
display: block;
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.1s;
|
transition: border-color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +477,21 @@ nav {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-thumb-preview {
|
||||||
|
display: block;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-thumb-preview .thumb-img {
|
||||||
|
display: block;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== SECTION TOOLBAR ===== */
|
/* ===== SECTION TOOLBAR ===== */
|
||||||
|
|
||||||
.section-toolbar {
|
.section-toolbar {
|
||||||
@@ -548,6 +670,24 @@ nav {
|
|||||||
.type-pill.type-bom { background: var(--badge-bom-bg); color: var(--badge-bom-text); }
|
.type-pill.type-bom { background: var(--badge-bom-bg); color: var(--badge-bom-text); }
|
||||||
.type-pill.type-digital { background: var(--badge-digital-bg); color: var(--badge-digital-text); }
|
.type-pill.type-digital { background: var(--badge-digital-bg); color: var(--badge-digital-text); }
|
||||||
|
|
||||||
|
.detail-inv-ref-link {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-inv-ref-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-component-link {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-component-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.inv-quantity {
|
.inv-quantity {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -694,6 +834,10 @@ nav {
|
|||||||
margin-bottom: 0.9rem;
|
margin-bottom: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.form-row label {
|
.form-row label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -737,14 +881,18 @@ nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Field input rows inside component dialog */
|
/* Field input rows inside component dialog */
|
||||||
.c-field-input-row {
|
#c-field-rows {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr auto;
|
grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto;
|
||||||
gap: 0.5rem;
|
gap: 0.35rem 0.6rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c-field-input-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.c-field-input-label {
|
.c-field-input-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -752,6 +900,7 @@ nav {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-field-unit-hint {
|
.c-field-unit-hint {
|
||||||
@@ -795,6 +944,71 @@ nav {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== TEMPLATES SECTION ===== */
|
||||||
|
|
||||||
|
.template-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-formatter {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmpl-preview-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmpl-preview-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent, #5b9cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-empty-note {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== TAB BAR ===== */
|
/* ===== TAB BAR ===== */
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
@@ -871,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 {
|
||||||
@@ -1019,6 +1270,17 @@ nav {
|
|||||||
|
|
||||||
/* ===== FORM ROW PAIR ===== */
|
/* ===== FORM ROW PAIR ===== */
|
||||||
|
|
||||||
|
.input-with-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-action .filter-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.form-row-pair {
|
.form-row-pair {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -1246,6 +1508,8 @@ nav {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.grid-cells {
|
.grid-cells {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -1257,6 +1521,11 @@ nav {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-cell.highlighted .grid-cell-img-wrap {
|
||||||
|
outline: 3px solid var(--accent, #5b9cf6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-cell-img-wrap {
|
.grid-cell-img-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1306,8 +1575,534 @@ nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid-cell-label {
|
.grid-cell-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3em;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-faint);
|
color: var(--text-dim);
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
padding-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell-count {
|
||||||
|
color: #4caf50;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GRID TYPE PILL ===== */
|
||||||
|
|
||||||
|
.type-pill.type-grid {
|
||||||
|
background: #2e7d4f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GRID CELL PICKER (inventory dialog) ===== */
|
||||||
|
|
||||||
|
.i-grid-cell-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i-grid-visual {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.igv-cell {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.igv-cell:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.igv-cell.igv-selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.igv-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CELL INVENTORY OVERLAY ===== */
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inventory-overlay {
|
||||||
|
position: fixed;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inventory-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inventory-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inventory-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
min-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inv-item {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inv-qty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inv-item-link {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
margin: 0 -0.25rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inv-item-link:hover {
|
||||||
|
background: var(--hover, rgba(255,255,255,0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inv-empty {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-inventory-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FILE PICKER DIALOG ===== */
|
||||||
|
|
||||||
|
.fp-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-name-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-filename {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-rename-input {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-focus);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-rename-filename {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-upload-section {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-field-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-upload-row input[type=text] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== DETAIL FILE LINKS ===== */
|
||||||
|
|
||||||
|
.detail-files-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumb {
|
||||||
|
width: auto;
|
||||||
|
height: 128px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-thumb {
|
||||||
|
width: auto;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-file-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-file-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== IMAGES ADMIN ===== */
|
||||||
|
|
||||||
|
.img-admin-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-thumb-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-thumb {
|
||||||
|
width: 80px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-uses {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-admin-use-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BINS ===== */
|
||||||
|
|
||||||
|
.bin-gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-card {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-card-img-wrap {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: #1a1a1a;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-card-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-card-unprocessed {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-card-img-wrap.has-image .bin-card-unprocessed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-card-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-editor-dims {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-editor-dims input {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-editor-preview-wrap {
|
||||||
|
width: 100%;
|
||||||
|
background: #0e0e0e;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 120px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-editor-preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 55vh;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-editor-no-image {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-editor-canvas {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-types-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-dims {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-content-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-content-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-content-qty {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-content-notes {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
<template id="t-section-components">
|
<template id="t-section-components">
|
||||||
<section class="section" id="section-components">
|
<section class="section" id="section-components">
|
||||||
<div class="split-layout">
|
<div class="split-layout">
|
||||||
<div class="list-pane">
|
<div class="list-pane" id="list-pane">
|
||||||
<input type="search" id="component-search" class="search-input" placeholder="Search…">
|
<input type="search" id="component-search" class="search-input" placeholder="Search…">
|
||||||
<input type="text" id="quick-add" class="quick-add-input" placeholder="New component… ↵" autocomplete="off" spellcheck="false">
|
<input type="text" id="quick-add" class="quick-add-input" placeholder="New component… ↵" autocomplete="off" spellcheck="false">
|
||||||
<div id="component-list" class="component-list"></div>
|
<div id="component-list" class="component-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="split-resizer" id="split-resizer"></div>
|
||||||
<div class="detail-pane" id="detail-pane"></div>
|
<div class="detail-pane" id="detail-pane"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-header-actions">
|
<div class="detail-header-actions">
|
||||||
<button class="btn btn-secondary detail-edit-btn">Edit</button>
|
<button class="btn btn-secondary detail-edit-btn">Edit</button>
|
||||||
|
<button class="btn btn-secondary detail-duplicate-btn">Duplicate</button>
|
||||||
<button class="btn btn-danger detail-delete-btn">Delete</button>
|
<button class="btn btn-danger detail-delete-btn">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +59,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-block-label">
|
||||||
|
Files
|
||||||
|
<button class="btn btn-secondary detail-link-file-btn">+ Link file</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-files-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="detail-block">
|
<div class="detail-block">
|
||||||
<div class="detail-block-label">
|
<div class="detail-block-label">
|
||||||
Inventory
|
Inventory
|
||||||
@@ -115,6 +125,7 @@
|
|||||||
<option value="physical">Physical</option>
|
<option value="physical">Physical</option>
|
||||||
<option value="bom">BOM / Drawing</option>
|
<option value="bom">BOM / Drawing</option>
|
||||||
<option value="digital">Digital / Note</option>
|
<option value="digital">Digital / Note</option>
|
||||||
|
<option value="grid">Grid cell</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary" id="btn-add-inventory">+ Add entry</button>
|
<button class="btn btn-primary" id="btn-add-inventory">+ Add entry</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +157,58 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== TEMPLATES SECTION ===== -->
|
||||||
|
<template id="t-section-templates">
|
||||||
|
<section class="section" id="section-templates">
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<span class="section-note">Formatters that compute display names from component fields</span>
|
||||||
|
<button class="btn btn-primary" id="btn-add-template">+ Add template</button>
|
||||||
|
</div>
|
||||||
|
<div id="template-list" class="template-list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="t-template-card">
|
||||||
|
<div class="template-card">
|
||||||
|
<div class="template-card-header">
|
||||||
|
<span class="template-card-name"></span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button class="btn-icon btn-edit" title="Edit">✎</button>
|
||||||
|
<button class="btn-icon btn-danger btn-delete" title="Delete">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre class="template-card-formatter"></pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="t-dialog-template">
|
||||||
|
<dialog id="dialog-template" class="app-dialog app-dialog-wide">
|
||||||
|
<h2 class="dialog-title"></h2>
|
||||||
|
<form method="dialog" id="form-template">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="tmpl-name">Name</label>
|
||||||
|
<input type="text" id="tmpl-name" required autocomplete="off" placeholder="e.g. Resistor">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="tmpl-formatter">Formatter <span class="label-hint">(JS arrow function, return null to skip)</span></label>
|
||||||
|
<textarea id="tmpl-formatter" rows="8" class="code-input" placeholder="(c) => { const r = c.fields?.resistance; if (!r) return null; return `Resistor ${r}`; }"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="tmpl-test-data">Test data <span class="label-hint">(optional — return a fields object to preview against)</span></label>
|
||||||
|
<textarea id="tmpl-test-data" rows="3" class="code-input" placeholder="return { resistance: '10k', mounting_tech: 'PTH' }"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="tmpl-preview-row">
|
||||||
|
<span class="label-hint">Preview:</span>
|
||||||
|
<span id="tmpl-preview" class="tmpl-preview-value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="tmpl-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="tmpl-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ===== FIELDS SECTION ===== -->
|
<!-- ===== FIELDS SECTION ===== -->
|
||||||
<template id="t-section-fields">
|
<template id="t-section-fields">
|
||||||
<section class="section" id="section-fields">
|
<section class="section" id="section-fields">
|
||||||
@@ -185,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">
|
||||||
@@ -213,7 +297,11 @@
|
|||||||
<a class="source-card-link" target="_blank" rel="noopener">
|
<a class="source-card-link" target="_blank" rel="noopener">
|
||||||
<img class="source-card-img" alt="">
|
<img class="source-card-img" alt="">
|
||||||
</a>
|
</a>
|
||||||
<div class="source-card-meta"></div>
|
<div class="source-card-footer">
|
||||||
|
<div class="source-card-meta"></div>
|
||||||
|
<div class="source-card-uses"></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm source-card-create-bin" hidden>+ Bin</button>
|
||||||
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete">✕</button>
|
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -301,7 +389,7 @@
|
|||||||
<div class="viewer-meta"></div>
|
<div class="viewer-meta"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-viewer-actions">
|
<div class="grid-viewer-actions">
|
||||||
<button class="btn btn-secondary" id="gv-back">← Back</button>
|
<button class="btn btn-secondary" id="gv-back">← Back</button>
|
||||||
<button class="btn btn-secondary" id="gv-edit-panels">Edit panels</button>
|
<button class="btn btn-secondary" id="gv-edit-panels">Edit panels</button>
|
||||||
<button class="btn btn-danger" id="gv-delete">Delete</button>
|
<button class="btn btn-danger" id="gv-delete">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,7 +409,7 @@
|
|||||||
|
|
||||||
<!-- ===== DIALOG: COMPONENT ===== -->
|
<!-- ===== DIALOG: COMPONENT ===== -->
|
||||||
<template id="t-dialog-component">
|
<template id="t-dialog-component">
|
||||||
<dialog id="dialog-component" class="app-dialog">
|
<dialog id="dialog-component" class="app-dialog app-dialog-wide">
|
||||||
<h2 class="dialog-title"></h2>
|
<h2 class="dialog-title"></h2>
|
||||||
<form method="dialog" id="form-component">
|
<form method="dialog" id="form-component">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -335,11 +423,14 @@
|
|||||||
<div class="form-section-label">Field values</div>
|
<div class="form-section-label">Field values</div>
|
||||||
<div id="c-field-rows"></div>
|
<div id="c-field-rows"></div>
|
||||||
<div class="form-row add-field-row">
|
<div class="form-row add-field-row">
|
||||||
<select id="c-add-field-select" class="filter-select">
|
<div class="input-with-action">
|
||||||
<option value="">— add a field —</option>
|
<select id="c-add-field-select" class="filter-select">
|
||||||
</select>
|
<option value="">— add a field —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="c-new-field">New…</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="c-cancel">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="c-cancel">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary" id="c-save">Save</button>
|
<button type="submit" class="btn btn-primary" id="c-save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,14 +440,17 @@
|
|||||||
|
|
||||||
<!-- ===== DIALOG: INVENTORY ENTRY ===== -->
|
<!-- ===== DIALOG: INVENTORY ENTRY ===== -->
|
||||||
<template id="t-dialog-inventory">
|
<template id="t-dialog-inventory">
|
||||||
<dialog id="dialog-inventory" class="app-dialog">
|
<dialog id="dialog-inventory" class="app-dialog app-dialog-wide">
|
||||||
<h2 class="dialog-title"></h2>
|
<h2 class="dialog-title"></h2>
|
||||||
<form method="dialog" id="form-inventory">
|
<form method="dialog" id="form-inventory">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="i-component">Component</label>
|
<label for="i-component">Component</label>
|
||||||
<select id="i-component" required class="filter-select wide">
|
<div class="input-with-action">
|
||||||
<option value="">— select component —</option>
|
<select id="i-component" required class="filter-select">
|
||||||
</select>
|
<option value="">— select component —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="i-new-component">New…</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="i-type">Location type</label>
|
<label for="i-type">Location type</label>
|
||||||
@@ -364,18 +458,26 @@
|
|||||||
<option value="physical">Physical location</option>
|
<option value="physical">Physical location</option>
|
||||||
<option value="bom">BOM / Drawing</option>
|
<option value="bom">BOM / Drawing</option>
|
||||||
<option value="digital">Digital / Note</option>
|
<option value="digital">Digital / Note</option>
|
||||||
|
<option value="grid">Grid cell</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="i-ref" id="i-ref-label">Location reference</label>
|
<label for="i-ref" id="i-ref-label">Location reference</label>
|
||||||
<input type="text" id="i-ref" autocomplete="off">
|
<input type="text" id="i-ref" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row" id="i-grid-row" hidden>
|
||||||
|
<label>Grid cell</label>
|
||||||
|
<div class="i-grid-cell-picker">
|
||||||
|
<select id="i-grid-select" class="filter-select"></select>
|
||||||
|
<div id="i-grid-visual" class="i-grid-visual"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="i-qty">Quantity</label>
|
<label for="i-qty">Quantity</label>
|
||||||
<input type="text" id="i-qty" autocomplete="off" placeholder="e.g. 10, ~50, see BOM">
|
<input type="text" id="i-qty" autocomplete="off" placeholder="e.g. 10, ~50, see BOM">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="i-notes">Notes</label>
|
<label for="i-notes">Notes <span class="label-hint">(for this location entry)</span></label>
|
||||||
<input type="text" id="i-notes" autocomplete="off">
|
<input type="text" id="i-notes" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
@@ -479,3 +581,259 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== DIALOG: FILE PICKER ===== -->
|
||||||
|
<template id="t-dialog-file-picker">
|
||||||
|
<dialog id="dialog-file-picker" class="app-dialog app-dialog-wide">
|
||||||
|
<h2 class="dialog-title">Files</h2>
|
||||||
|
<div id="fp-list" class="fp-list"></div>
|
||||||
|
<div class="fp-upload-section">
|
||||||
|
<div class="form-section-label">Upload new PDF</div>
|
||||||
|
<div class="fp-upload-row">
|
||||||
|
<input type="file" id="fp-file-input" accept=".pdf,application/pdf">
|
||||||
|
</div>
|
||||||
|
<div class="fp-upload-row">
|
||||||
|
<label class="fp-field-label">Display name</label>
|
||||||
|
<input type="text" id="fp-upload-name" placeholder="Human readable label" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="fp-upload-row">
|
||||||
|
<label class="fp-field-label">Filename on disk</label>
|
||||||
|
<input type="text" id="fp-upload-filename" placeholder="e.g. lm741.pdf" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="fp-upload-row">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="fp-upload-btn">Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="fp-cancel">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== BINS SECTION ===== -->
|
||||||
|
<template id="t-section-bins">
|
||||||
|
<section class="section" id="section-bins">
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn" id="btn-tab-bins">Bins</button>
|
||||||
|
<button class="tab-btn" id="btn-tab-bin-sources">Source images</button>
|
||||||
|
<button class="tab-btn" id="btn-tab-bin-types">Types</button>
|
||||||
|
</div>
|
||||||
|
<label class="btn btn-secondary" id="btn-upload-bin-sources" hidden>
|
||||||
|
+ Upload
|
||||||
|
<input type="file" accept="image/*" multiple hidden id="bin-source-upload-input">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" id="btn-add-bin-type" hidden>+ Add type</button>
|
||||||
|
</div>
|
||||||
|
<div id="tab-bins-content">
|
||||||
|
<div class="bin-gallery" id="bin-gallery"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-bin-sources-content" hidden>
|
||||||
|
<div id="bin-source-image-list" class="source-gallery"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-bin-types-content" hidden>
|
||||||
|
<div id="bin-types-list" class="bin-types-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="t-bin-type-row">
|
||||||
|
<div class="bin-type-row">
|
||||||
|
<div class="bin-type-info">
|
||||||
|
<span class="bin-type-name"></span>
|
||||||
|
<span class="bin-type-dims"></span>
|
||||||
|
<span class="bin-type-desc"></span>
|
||||||
|
</div>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button class="btn-icon btn-edit" title="Edit">✎</button>
|
||||||
|
<button class="btn-icon btn-danger btn-delete" title="Delete">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="t-bin-card">
|
||||||
|
<div class="bin-card">
|
||||||
|
<div class="bin-card-img-wrap">
|
||||||
|
<img class="bin-card-img" alt="">
|
||||||
|
<div class="bin-card-unprocessed">Not processed</div>
|
||||||
|
</div>
|
||||||
|
<div class="bin-card-footer">
|
||||||
|
<span class="bin-card-name"></span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button class="btn-icon btn-edit" title="Edit corners">✎</button>
|
||||||
|
<button class="btn-icon btn-danger btn-delete" title="Delete">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== DIALOG: BIN EDITOR ===== -->
|
||||||
|
<template id="t-dialog-bin-editor">
|
||||||
|
<dialog id="dialog-bin-editor" class="app-dialog app-dialog-wide">
|
||||||
|
<h2 class="dialog-title">Edit bin</h2>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" id="bin-editor-name" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="bin-editor-type">
|
||||||
|
<option value="">— Custom —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default view: processed image -->
|
||||||
|
<div id="bin-editor-view-image">
|
||||||
|
<div class="bin-editor-preview-wrap">
|
||||||
|
<img id="bin-editor-preview" class="bin-editor-preview-img" alt="" hidden>
|
||||||
|
<div id="bin-editor-no-image" class="bin-editor-no-image">Not yet processed — click "Adjust corners" to set up</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="bin-editor-go-corners">Adjust corners…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corners canvas (revealed on demand) -->
|
||||||
|
<div id="bin-editor-view-corners" hidden>
|
||||||
|
<div class="form-row" id="bin-editor-dims-row">
|
||||||
|
<label>Dimensions (mm)</label>
|
||||||
|
<div class="bin-editor-dims">
|
||||||
|
<input type="number" id="bin-editor-width" placeholder="W" min="1" step="1">
|
||||||
|
<span>×</span>
|
||||||
|
<input type="number" id="bin-editor-height" placeholder="H" min="1" step="1">
|
||||||
|
<span class="form-hint">Leave blank to infer from corners</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="bin-editor-canvas" class="bin-editor-canvas"></canvas>
|
||||||
|
<button type="button" class="btn btn-link btn-sm" id="bin-editor-go-back">← Back to image</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs: Fields | Contents -->
|
||||||
|
<div class="tab-bar" id="bin-editor-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="fields">Fields</button>
|
||||||
|
<button class="tab-btn" data-tab="contents">Contents</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bin-editor-tab-fields">
|
||||||
|
<div id="bin-field-rows"></div>
|
||||||
|
<div class="form-row add-field-row">
|
||||||
|
<div class="input-with-action">
|
||||||
|
<select id="bin-add-field-select" class="filter-select">
|
||||||
|
<option value="">— add a field —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="bin-new-field">New…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bin-editor-tab-contents" hidden>
|
||||||
|
<div id="bin-contents-list"></div>
|
||||||
|
<div class="form-row" style="margin-top:0.5rem">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="bin-add-content">+ Add item</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="bin-editor-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="bin-editor-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== TEMPLATE: BIN CONTENT ROW ===== -->
|
||||||
|
<template id="t-bin-content-row">
|
||||||
|
<div class="bin-content-row">
|
||||||
|
<span class="bin-content-name"></span>
|
||||||
|
<span class="bin-content-qty"></span>
|
||||||
|
<span class="bin-content-notes"></span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button class="btn-icon btn-edit" title="Edit">✎</button>
|
||||||
|
<button class="btn-icon btn-danger btn-delete" title="Remove">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== DIALOG: BIN CONTENT ITEM ===== -->
|
||||||
|
<template id="t-dialog-bin-content">
|
||||||
|
<dialog id="dialog-bin-content" class="app-dialog">
|
||||||
|
<h2 class="dialog-title">Add item</h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="bc-type" class="filter-select wide">
|
||||||
|
<option value="component">Component</option>
|
||||||
|
<option value="item">Free text</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="bc-component-row">
|
||||||
|
<label>Component</label>
|
||||||
|
<select id="bc-component" class="filter-select wide">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="bc-name-row" hidden>
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" id="bc-name" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Quantity</label>
|
||||||
|
<input type="text" id="bc-quantity" autocomplete="off" placeholder="e.g. 10, ~50, full">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Notes</label>
|
||||||
|
<input type="text" id="bc-notes" autocomplete="off" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="bc-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="bc-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="t-dialog-bin-type">
|
||||||
|
<dialog id="dialog-bin-type" class="app-dialog">
|
||||||
|
<h2 class="dialog-title"></h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" id="bt-name" autocomplete="off" placeholder="e.g. Sortimo L-Boxx small">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Dimensions (mm)</label>
|
||||||
|
<div class="bin-editor-dims">
|
||||||
|
<input type="number" id="bt-width" placeholder="W" min="1" step="1">
|
||||||
|
<span>×</span>
|
||||||
|
<input type="number" id="bt-height" placeholder="H" min="1" step="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" id="bt-description" autocomplete="off" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
<div class="form-section-label">Field values</div>
|
||||||
|
<div id="bt-field-rows"></div>
|
||||||
|
<div class="form-row add-field-row">
|
||||||
|
<div class="input-with-action">
|
||||||
|
<select id="bt-add-field-select" class="filter-select">
|
||||||
|
<option value="">— add a field —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="bt-new-field">New…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="bt-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="bt-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== CELL INVENTORY OVERLAY ===== -->
|
||||||
|
<template id="t-cell-inventory">
|
||||||
|
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
|
||||||
|
<div class="cell-inventory-header">
|
||||||
|
<span class="cell-inventory-title"></span>
|
||||||
|
<button type="button" class="btn-icon" id="cell-inv-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="cell-inventory-list" id="cell-inventory-list"></div>
|
||||||
|
<div class="cell-inventory-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="cell-inv-add">+ Add entry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -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,10 +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] = {
|
const dx = img_pos.x - this.#drag_prev_img.x;
|
||||||
x: Math.max(0, Math.min(this.#img.width, img_pos.x)),
|
const dy = img_pos.y - this.#drag_prev_img.y;
|
||||||
y: Math.max(0, Math.min(this.#img.height, img_pos.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;
|
||||||
@@ -170,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);
|
||||||
@@ -258,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
537
server.mjs
537
server.mjs
@@ -3,11 +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 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,
|
||||||
@@ -15,9 +16,37 @@ import {
|
|||||||
list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft,
|
list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft,
|
||||||
list_source_images, get_source_image, add_source_image, delete_source_image,
|
list_source_images, get_source_image, add_source_image, delete_source_image,
|
||||||
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_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 });
|
||||||
|
|
||||||
|
// 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());
|
||||||
@@ -38,10 +67,77 @@ const upload = multer({
|
|||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pdf_upload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: './data/pdfs',
|
||||||
|
filename: (req, file, cb) => cb(null, generate_id() + '.pdf'),
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype === 'application/pdf' || extname(file.originalname).toLowerCase() === '.pdf') {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only PDF files are allowed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/pdf', express.static('./data/pdfs'));
|
||||||
|
|
||||||
function remove_image_file(img_id) {
|
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;
|
||||||
|
|
||||||
|
// Atomically rename src -> dst, failing if dst already exists.
|
||||||
|
// Uses renameat2(RENAME_NOREPLACE) via tools/mv-sync.
|
||||||
|
// Throws on unexpected errors; returns false if dst exists.
|
||||||
|
function rename_no_replace(src, dst) {
|
||||||
|
try {
|
||||||
|
execFileSync(MV_SYNC, [src, dst]);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 1) return false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize_pdf_filename(display_name) {
|
||||||
|
const base = display_name
|
||||||
|
.replace(/\.pdf$/i, '')
|
||||||
|
.replace(/[^a-zA-Z0-9._\- ]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
|| 'document';
|
||||||
|
return base + '.pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate_pdf_thumb(pdf_path, thumb_prefix) {
|
||||||
|
// Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable.
|
||||||
|
try {
|
||||||
|
execFileSync('pdftoppm', [
|
||||||
|
'-png', '-singlefile', '-scale-to', '512',
|
||||||
|
'-f', '1', '-l', '1',
|
||||||
|
pdf_path, thumb_prefix,
|
||||||
|
]);
|
||||||
|
return thumb_prefix.split('/').pop() + '.png';
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Field definitions
|
// Field definitions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -115,11 +211,12 @@ app.get('/api/components/:id', (req, res) => {
|
|||||||
app.put('/api/components/:id', (req, res) => {
|
app.put('/api/components/:id', (req, res) => {
|
||||||
const existing = get_component(req.params.id);
|
const existing = get_component(req.params.id);
|
||||||
if (!existing) return fail(res, 'not found', 404);
|
if (!existing) return fail(res, 'not found', 404);
|
||||||
const { name, description, fields } = req.body;
|
const { name, description, fields, file_ids } = req.body;
|
||||||
const updated = { ...existing, updated_at: Date.now() };
|
const updated = { ...existing, updated_at: Date.now() };
|
||||||
if (name !== undefined) updated.name = name.trim();
|
if (name !== undefined) updated.name = name.trim();
|
||||||
if (description !== undefined) updated.description = description.trim();
|
if (description !== undefined) updated.description = description.trim();
|
||||||
if (fields !== undefined) updated.fields = fields;
|
if (fields !== undefined) updated.fields = fields;
|
||||||
|
if (file_ids !== undefined) updated.file_ids = file_ids;
|
||||||
set_component(updated);
|
set_component(updated);
|
||||||
ok(res, { component: updated });
|
ok(res, { component: updated });
|
||||||
});
|
});
|
||||||
@@ -156,7 +253,8 @@ app.get('/api/inventory', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/inventory', (req, res) => {
|
app.post('/api/inventory', (req, res) => {
|
||||||
const { component_id, location_type, location_ref = '', quantity = '', notes = '' } = req.body;
|
const { component_id, location_type, location_ref = '', quantity = '', notes = '',
|
||||||
|
grid_id = null, grid_row = null, grid_col = null } = req.body;
|
||||||
if (!component_id) return fail(res, 'component_id is required');
|
if (!component_id) return fail(res, 'component_id is required');
|
||||||
if (!location_type) return fail(res, 'location_type is required');
|
if (!location_type) return fail(res, 'location_type is required');
|
||||||
if (!get_component(component_id)) return fail(res, 'component not found', 404);
|
if (!get_component(component_id)) return fail(res, 'component not found', 404);
|
||||||
@@ -168,6 +266,9 @@ app.post('/api/inventory', (req, res) => {
|
|||||||
location_ref: String(location_ref).trim(),
|
location_ref: String(location_ref).trim(),
|
||||||
quantity: String(quantity).trim(),
|
quantity: String(quantity).trim(),
|
||||||
notes: String(notes).trim(),
|
notes: String(notes).trim(),
|
||||||
|
grid_id: grid_id ?? null,
|
||||||
|
grid_row: grid_row != null ? parseInt(grid_row) : null,
|
||||||
|
grid_col: grid_col != null ? parseInt(grid_col) : null,
|
||||||
images: [],
|
images: [],
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
@@ -185,6 +286,9 @@ app.put('/api/inventory/:id', (req, res) => {
|
|||||||
if (location_ref !== undefined) updated.location_ref = String(location_ref).trim();
|
if (location_ref !== undefined) updated.location_ref = String(location_ref).trim();
|
||||||
if (quantity !== undefined) updated.quantity = String(quantity).trim();
|
if (quantity !== undefined) updated.quantity = String(quantity).trim();
|
||||||
if (notes !== undefined) updated.notes = String(notes).trim();
|
if (notes !== undefined) updated.notes = String(notes).trim();
|
||||||
|
if (req.body.grid_id !== undefined) updated.grid_id = req.body.grid_id ?? null;
|
||||||
|
if (req.body.grid_row !== undefined) updated.grid_row = req.body.grid_row != null ? parseInt(req.body.grid_row) : null;
|
||||||
|
if (req.body.grid_col !== undefined) updated.grid_col = req.body.grid_col != null ? parseInt(req.body.grid_col) : null;
|
||||||
set_inventory_entry(updated);
|
set_inventory_entry(updated);
|
||||||
ok(res, { entry: updated });
|
ok(res, { entry: updated });
|
||||||
});
|
});
|
||||||
@@ -260,12 +364,14 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
|
|||||||
if (!req.files?.length) return fail(res, 'no files');
|
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);
|
||||||
@@ -274,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);
|
||||||
@@ -416,6 +533,408 @@ app.delete('/api/grid-images/:id', (req, res) => {
|
|||||||
ok(res);
|
ok(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component templates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/component-templates', (req, res) => {
|
||||||
|
ok(res, { templates: list_component_templates() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/component-templates', (req, res) => {
|
||||||
|
const { name, formatter } = req.body;
|
||||||
|
if (!name) return fail(res, 'name is required');
|
||||||
|
const tmpl = { id: generate_id(), name, formatter: formatter ?? '', created_at: Date.now(), updated_at: Date.now() };
|
||||||
|
set_component_template(tmpl);
|
||||||
|
ok(res, { template: tmpl });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/component-templates/:id', (req, res) => {
|
||||||
|
const existing = get_component_template(req.params.id);
|
||||||
|
if (!existing) return fail(res, 'not found', 404);
|
||||||
|
const updated = { ...existing, updated_at: Date.now() };
|
||||||
|
if (req.body.name !== undefined) updated.name = req.body.name;
|
||||||
|
if (req.body.formatter !== undefined) updated.formatter = req.body.formatter;
|
||||||
|
set_component_template(updated);
|
||||||
|
ok(res, { template: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/component-templates/:id', (req, res) => {
|
||||||
|
if (!get_component_template(req.params.id)) return fail(res, 'not found', 404);
|
||||||
|
delete_component_template(req.params.id);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PDF files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/pdfs', (req, res) => {
|
||||||
|
ok(res, { pdfs: list_pdfs() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => {
|
||||||
|
if (!req.file) return fail(res, 'no file uploaded');
|
||||||
|
const display_name = req.body.display_name?.trim() || req.file.originalname;
|
||||||
|
const filename = sanitize_pdf_filename(req.body.filename?.trim() || req.file.originalname);
|
||||||
|
const all = list_pdfs();
|
||||||
|
if (all.some(p => p.display_name === display_name)) {
|
||||||
|
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
|
||||||
|
return fail(res, 'a file with that display name already exists');
|
||||||
|
}
|
||||||
|
if (all.some(p => p.filename === filename)) {
|
||||||
|
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
|
||||||
|
return fail(res, 'a file with that filename already exists');
|
||||||
|
}
|
||||||
|
const id = generate_id();
|
||||||
|
const temp_path = join('./data/pdfs', req.file.filename);
|
||||||
|
const final_path = join('./data/pdfs', filename);
|
||||||
|
if (!rename_no_replace(temp_path, final_path)) {
|
||||||
|
try { unlinkSync(temp_path); } catch {}
|
||||||
|
return fail(res, 'a file with that filename already exists on disk');
|
||||||
|
}
|
||||||
|
const thumb_prefix = join('./data/pdfs', id + '-thumb');
|
||||||
|
const thumb_file = generate_pdf_thumb(final_path, thumb_prefix);
|
||||||
|
const pdf = { id, filename, display_name, original_name: req.file.originalname, size: req.file.size, thumb_filename: thumb_file, uploaded_at: Date.now() };
|
||||||
|
set_pdf(pdf);
|
||||||
|
ok(res, { pdf });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/pdfs/:id', (req, res) => {
|
||||||
|
const pdf = get_pdf(req.params.id);
|
||||||
|
if (!pdf) return fail(res, 'not found', 404);
|
||||||
|
const display_name = req.body.display_name?.trim();
|
||||||
|
const filename = req.body.filename?.trim() ? sanitize_pdf_filename(req.body.filename.trim()) : pdf.filename;
|
||||||
|
if (!display_name) return fail(res, 'display_name is required');
|
||||||
|
const all = list_pdfs();
|
||||||
|
if (all.some(p => p.display_name === display_name && p.id !== pdf.id))
|
||||||
|
return fail(res, 'a file with that display name already exists');
|
||||||
|
if (all.some(p => p.filename === filename && p.id !== pdf.id))
|
||||||
|
return fail(res, 'a file with that filename already exists');
|
||||||
|
if (filename !== pdf.filename) {
|
||||||
|
const new_path = join('./data/pdfs', filename);
|
||||||
|
if (!rename_no_replace(join('./data/pdfs', pdf.filename), new_path))
|
||||||
|
return fail(res, 'a file with that filename already exists on disk');
|
||||||
|
}
|
||||||
|
const updated = { ...pdf, display_name, filename };
|
||||||
|
set_pdf(updated);
|
||||||
|
ok(res, { pdf: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/pdfs/:id', (req, res) => {
|
||||||
|
const pdf = get_pdf(req.params.id);
|
||||||
|
if (!pdf) return fail(res, 'not found', 404);
|
||||||
|
try { unlinkSync(join('./data/pdfs', pdf.filename)); } catch {}
|
||||||
|
if (pdf.thumb_filename) { try { unlinkSync(join('./data/pdfs', pdf.thumb_filename)); } catch {} }
|
||||||
|
delete_pdf(req.params.id);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Maintenance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.post('/api/maintenance/purge-missing-sources', (req, res) => {
|
||||||
|
const sources = list_source_images();
|
||||||
|
const removed = [];
|
||||||
|
for (const src of sources) {
|
||||||
|
if (!existsSync(join('./data/images', src.id))) {
|
||||||
|
delete_source_image(src.id);
|
||||||
|
removed.push(src.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok(res, { removed, total: sources.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/maintenance/pdf-thumbs', (req, res) => {
|
||||||
|
const pdfs = list_pdfs();
|
||||||
|
let generated = 0;
|
||||||
|
for (const pdf of pdfs) {
|
||||||
|
if (pdf.thumb_filename) continue;
|
||||||
|
const thumb_prefix = join('./data/pdfs', pdf.id + '-thumb');
|
||||||
|
const thumb_file = generate_pdf_thumb(join('./data/pdfs', pdf.filename), thumb_prefix);
|
||||||
|
if (thumb_file) {
|
||||||
|
set_pdf({ ...pdf, thumb_filename: thumb_file });
|
||||||
|
generated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok(res, { generated, total: pdfs.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bin types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/bin-types', (req, res) => {
|
||||||
|
ok(res, { bin_types: list_bin_types() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/bin-types', (req, res) => {
|
||||||
|
const { name, phys_w, phys_h, description = '', fields = {} } = req.body;
|
||||||
|
if (!name?.trim()) return fail(res, 'name is required');
|
||||||
|
if (!(phys_w > 0)) return fail(res, 'phys_w must be a positive number');
|
||||||
|
if (!(phys_h > 0)) return fail(res, 'phys_h must be a positive number');
|
||||||
|
const bt = {
|
||||||
|
id: generate_id(),
|
||||||
|
name: name.trim(),
|
||||||
|
phys_w: Number(phys_w),
|
||||||
|
phys_h: Number(phys_h),
|
||||||
|
description: description.trim(),
|
||||||
|
fields: typeof fields === 'object' && fields !== null ? fields : {},
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
set_bin_type(bt);
|
||||||
|
ok(res, { bin_type: bt });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/bin-types/:id', (req, res) => {
|
||||||
|
const existing = get_bin_type(req.params.id);
|
||||||
|
if (!existing) return fail(res, 'not found', 404);
|
||||||
|
const { name, phys_w, phys_h, description, fields } = req.body;
|
||||||
|
const updated = { ...existing, updated_at: Date.now() };
|
||||||
|
if (name !== undefined) updated.name = name.trim();
|
||||||
|
if (phys_w !== undefined) {
|
||||||
|
if (!(Number(phys_w) > 0)) return fail(res, 'phys_w must be a positive number');
|
||||||
|
updated.phys_w = Number(phys_w);
|
||||||
|
}
|
||||||
|
if (phys_h !== undefined) {
|
||||||
|
if (!(Number(phys_h) > 0)) return fail(res, 'phys_h must be a positive number');
|
||||||
|
updated.phys_h = Number(phys_h);
|
||||||
|
}
|
||||||
|
if (description !== undefined) updated.description = description.trim();
|
||||||
|
if (fields !== undefined && typeof fields === 'object' && fields !== null) updated.fields = fields;
|
||||||
|
set_bin_type(updated);
|
||||||
|
ok(res, { bin_type: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/bin-types/:id', (req, res) => {
|
||||||
|
if (!get_bin_type(req.params.id)) return fail(res, 'not found', 404);
|
||||||
|
const in_use = list_bins().find(b => b.type_id === req.params.id);
|
||||||
|
if (in_use) return fail(res, `In use by bin "${in_use.name}"`, 409);
|
||||||
|
delete_bin_type(req.params.id);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/bins', (req, res) => {
|
||||||
|
ok(res, { bins: list_bins() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/bins/:id', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
ok(res, { bin });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a bin from an already-uploaded source image
|
||||||
|
app.post('/api/bins/from-source', (req, res) => {
|
||||||
|
const { source_id, name = '', type_id = null } = req.body;
|
||||||
|
if (!source_id) return fail(res, 'source_id is required');
|
||||||
|
const src = get_source_image(source_id);
|
||||||
|
if (!src) return fail(res, 'source image not found', 404);
|
||||||
|
if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404);
|
||||||
|
|
||||||
|
if (!src.uses?.includes('bin')) {
|
||||||
|
add_source_image({ ...src, uses: [...(src.uses ?? []), 'bin'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bt = type_id ? get_bin_type(type_id) : null;
|
||||||
|
const mx = Math.round(src.width * 0.15);
|
||||||
|
const my = Math.round(src.height * 0.15);
|
||||||
|
const bin = {
|
||||||
|
id: generate_id(),
|
||||||
|
name: name.trim() || src.original_name?.replace(/\.[^.]+$/, '') || 'Bin',
|
||||||
|
type_id,
|
||||||
|
source_id,
|
||||||
|
source_w: src.width,
|
||||||
|
source_h: src.height,
|
||||||
|
corners: [
|
||||||
|
{ x: mx, y: my },
|
||||||
|
{ x: src.width - mx, y: my },
|
||||||
|
{ x: src.width - mx, y: src.height - my },
|
||||||
|
{ x: mx, y: src.height - my },
|
||||||
|
],
|
||||||
|
phys_w: bt?.phys_w ?? null,
|
||||||
|
phys_h: bt?.phys_h ?? null,
|
||||||
|
image_filename: null,
|
||||||
|
fields: {},
|
||||||
|
contents: [],
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
set_bin(bin);
|
||||||
|
ok(res, { bin });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload a source image for a bin and create the bin record (no processing yet)
|
||||||
|
app.post('/api/bins', upload.single('image'), async (req, res) => {
|
||||||
|
if (!req.file) return fail(res, 'no image uploaded');
|
||||||
|
const { name = '', type_id = null } = req.body;
|
||||||
|
if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404);
|
||||||
|
const final_name = settle_image_filename(req.file.filename, req.file.originalname);
|
||||||
|
const meta = await sharp(join('./data/images', final_name)).metadata();
|
||||||
|
|
||||||
|
add_source_image({
|
||||||
|
id: final_name,
|
||||||
|
original_name: req.file.originalname,
|
||||||
|
width: meta.width,
|
||||||
|
height: meta.height,
|
||||||
|
uses: ['bin'],
|
||||||
|
created_at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bt = type_id ? get_bin_type(type_id) : null;
|
||||||
|
const mx = Math.round(meta.width * 0.15);
|
||||||
|
const my = Math.round(meta.height * 0.15);
|
||||||
|
const bin = {
|
||||||
|
id: generate_id(),
|
||||||
|
name: name.trim() || 'Bin',
|
||||||
|
type_id,
|
||||||
|
source_id: final_name,
|
||||||
|
source_w: meta.width,
|
||||||
|
source_h: meta.height,
|
||||||
|
corners: [
|
||||||
|
{ x: mx, y: my },
|
||||||
|
{ x: meta.width - mx, y: my },
|
||||||
|
{ x: meta.width - mx, y: meta.height - my },
|
||||||
|
{ x: mx, y: meta.height - my },
|
||||||
|
],
|
||||||
|
phys_w: bt?.phys_w ?? null,
|
||||||
|
phys_h: bt?.phys_h ?? null,
|
||||||
|
image_filename: null,
|
||||||
|
fields: {},
|
||||||
|
contents: [],
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
set_bin(bin);
|
||||||
|
ok(res, { bin });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update corners (and re-process image)
|
||||||
|
app.put('/api/bins/:id/corners', async (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
const { corners, phys_w, phys_h } = req.body;
|
||||||
|
if (!corners || corners.length !== 4) return fail(res, 'corners must be array of 4 points');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (bin.image_filename) remove_image_file(bin.image_filename);
|
||||||
|
|
||||||
|
const source_path = join('./data/images', bin.source_id);
|
||||||
|
let bin_w, bin_h;
|
||||||
|
if (phys_w > 0 && phys_h > 0) {
|
||||||
|
// Use physical aspect ratio scaled to the same area as computed size
|
||||||
|
const computed = compute_bin_size(corners);
|
||||||
|
const area = computed.bin_w * computed.bin_h;
|
||||||
|
const aspect = phys_w / phys_h;
|
||||||
|
bin_h = Math.round(Math.sqrt(area / aspect));
|
||||||
|
bin_w = Math.round(bin_h * aspect);
|
||||||
|
} else {
|
||||||
|
({ bin_w, bin_h } = compute_bin_size(corners));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = await process_grid_image(source_path, corners, 1, 1, bin_w, bin_h, './data/images');
|
||||||
|
const image_filename = cells[0][0];
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...bin, corners, image_filename, bin_w, bin_h,
|
||||||
|
phys_w: phys_w > 0 ? phys_w : (bin.phys_w ?? null),
|
||||||
|
phys_h: phys_h > 0 ? phys_h : (bin.phys_h ?? null),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
set_bin(updated);
|
||||||
|
ok(res, { bin: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
fail(res, err.message, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update name / type / fields
|
||||||
|
app.put('/api/bins/:id', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
const { name, type_id, fields } = req.body;
|
||||||
|
if (type_id !== undefined && type_id !== null && !get_bin_type(type_id)) {
|
||||||
|
return fail(res, 'bin type not found', 404);
|
||||||
|
}
|
||||||
|
const updated = { ...bin, updated_at: Date.now() };
|
||||||
|
if (name !== undefined) updated.name = name.trim() || 'Bin';
|
||||||
|
if (type_id !== undefined) {
|
||||||
|
updated.type_id = type_id;
|
||||||
|
const bt = type_id ? get_bin_type(type_id) : null;
|
||||||
|
if (bt) { updated.phys_w = bt.phys_w; updated.phys_h = bt.phys_h; }
|
||||||
|
}
|
||||||
|
if (fields !== undefined && typeof fields === 'object' && fields !== null) updated.fields = fields;
|
||||||
|
set_bin(updated);
|
||||||
|
ok(res, { bin: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a content item to a bin
|
||||||
|
app.post('/api/bins/:id/contents', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
const { type, component_id, name, quantity = '', notes = '' } = req.body;
|
||||||
|
if (type !== 'component' && type !== 'item') return fail(res, 'type must be "component" or "item"');
|
||||||
|
if (type === 'component' && !component_id) return fail(res, 'component_id is required for type component');
|
||||||
|
if (type === 'item' && !name?.trim()) return fail(res, 'name is required for type item');
|
||||||
|
const item = {
|
||||||
|
id: generate_id(),
|
||||||
|
type,
|
||||||
|
component_id: type === 'component' ? component_id : null,
|
||||||
|
name: type === 'item' ? name.trim() : null,
|
||||||
|
quantity: String(quantity).trim(),
|
||||||
|
notes: String(notes).trim(),
|
||||||
|
created_at: Date.now(),
|
||||||
|
};
|
||||||
|
const updated = { ...bin, contents: [...(bin.contents ?? []), item], updated_at: Date.now() };
|
||||||
|
set_bin(updated);
|
||||||
|
ok(res, { bin: updated, item });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a content item
|
||||||
|
app.put('/api/bins/:id/contents/:cid', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
const idx = (bin.contents ?? []).findIndex(c => c.id === req.params.cid);
|
||||||
|
if (idx === -1) return fail(res, 'content item not found', 404);
|
||||||
|
const item = bin.contents[idx];
|
||||||
|
const { quantity, notes, name } = req.body;
|
||||||
|
const updated_item = { ...item };
|
||||||
|
if (quantity !== undefined) updated_item.quantity = String(quantity).trim();
|
||||||
|
if (notes !== undefined) updated_item.notes = String(notes).trim();
|
||||||
|
if (name !== undefined && item.type === 'item') updated_item.name = name.trim();
|
||||||
|
const new_contents = bin.contents.map((c, i) => i === idx ? updated_item : c);
|
||||||
|
const updated = { ...bin, contents: new_contents, updated_at: Date.now() };
|
||||||
|
set_bin(updated);
|
||||||
|
ok(res, { bin: updated, item: updated_item });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a content item
|
||||||
|
app.delete('/api/bins/:id/contents/:cid', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
const exists = (bin.contents ?? []).some(c => c.id === req.params.cid);
|
||||||
|
if (!exists) return fail(res, 'content item not found', 404);
|
||||||
|
const updated = { ...bin, contents: bin.contents.filter(c => c.id !== req.params.cid), updated_at: Date.now() };
|
||||||
|
set_bin(updated);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/bins/:id', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
// Only delete the processed output — source image is managed independently
|
||||||
|
if (bin.image_filename) remove_image_file(bin.image_filename);
|
||||||
|
delete_bin(bin.id);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
// SPA fallback — serve index.html for any non-API, non-asset path
|
// 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));
|
||||||
|
|||||||
10
tools/Makefile
Normal file
10
tools/Makefile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CC = gcc
|
||||||
|
CFLAGS = -O2 -Wall -Wextra
|
||||||
|
|
||||||
|
all: mv-sync
|
||||||
|
|
||||||
|
mv-sync: mv-sync.c
|
||||||
|
$(CC) $(CFLAGS) -o mv-sync mv-sync.c
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f mv-sync
|
||||||
42
tools/mv-sync.c
Normal file
42
tools/mv-sync.c
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* mv-sync: atomic rename that fails if the destination already exists.
|
||||||
|
*
|
||||||
|
* Uses renameat2(2) with RENAME_NOREPLACE (Linux 3.15+).
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 success
|
||||||
|
* 1 rename failed (reason on stderr)
|
||||||
|
* 2 wrong number of arguments
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define _GNU_SOURCE
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/syscall.h>
|
||||||
|
|
||||||
|
#ifndef RENAME_NOREPLACE
|
||||||
|
#define RENAME_NOREPLACE (1 << 0)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
if (argc != 3) {
|
||||||
|
fprintf(stderr, "usage: mv-sync <src> <dst>\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
long ret = syscall(SYS_renameat2,
|
||||||
|
AT_FDCWD, argv[1],
|
||||||
|
AT_FDCWD, argv[2],
|
||||||
|
RENAME_NOREPLACE);
|
||||||
|
|
||||||
|
if (ret != 0) {
|
||||||
|
fprintf(stderr, "mv-sync: rename \"%s\" -> \"%s\": %s\n",
|
||||||
|
argv[1], argv[2], strerror(errno));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
362
ui-structure.md
Normal file
362
ui-structure.md
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# UI Structure
|
||||||
|
|
||||||
|
Current UI inventory — sections, widgets, templates, and recurring patterns.
|
||||||
|
Intended as a reference for the rewrite and for defining a higher-level UI
|
||||||
|
representation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Top bar with 7 section buttons + a maintenance dropdown (⚙):
|
||||||
|
|
||||||
|
| Button | Section | Default? |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| Components | Split-pane list/detail | ✓ |
|
||||||
|
| Inventory | Table | |
|
||||||
|
| Fields | Table | |
|
||||||
|
| Grids | Tabbed (Grids / Source images) + sub-views | |
|
||||||
|
| Templates | Card list | |
|
||||||
|
| Bins | Tabbed (Bins / Source images / Types) | |
|
||||||
|
| Images | Admin list | |
|
||||||
|
|
||||||
|
Maintenance dropdown (not a section):
|
||||||
|
- Generate missing PDF thumbnails
|
||||||
|
- Remove orphaned source image entries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
### Components
|
||||||
|
**Layout:** Resizable split-pane (width persisted in localStorage)
|
||||||
|
|
||||||
|
**Left — master list:**
|
||||||
|
- Search input (filters by name + field values)
|
||||||
|
- Quick-add input (creates component inline)
|
||||||
|
- List of `t-component-row` items (name + colored field-value badge tags)
|
||||||
|
|
||||||
|
**Right — detail panel:**
|
||||||
|
- Placeholder when nothing selected
|
||||||
|
- When selected: name, description, Edit / Duplicate / Delete actions
|
||||||
|
- Fields block — name/value rows
|
||||||
|
- Images block — thumbnail gallery + upload button
|
||||||
|
- Files block — linked PDFs + file picker button
|
||||||
|
- Inventory block — location entries (each with edit/delete) + add button
|
||||||
|
|
||||||
|
**Dialogs opened from here:** component edit, field create, inventory entry, file
|
||||||
|
picker, confirm delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
**Layout:** Toolbar + table
|
||||||
|
|
||||||
|
- Filter: text search + location-type dropdown (All / Physical / BOM / Digital / Grid)
|
||||||
|
- Table columns: Component | Type | Location/Ref | Qty | Notes | Actions (edit, delete)
|
||||||
|
- Component name cells are links → `/components/:id`
|
||||||
|
- Grid location cells link to the grid viewer at that cell
|
||||||
|
- "+ Add entry" button in toolbar
|
||||||
|
|
||||||
|
**Dialogs opened from here:** inventory entry edit/create, confirm delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
**Layout:** Toolbar + table
|
||||||
|
|
||||||
|
- Table columns: Name | Unit | Description | Actions (edit, delete)
|
||||||
|
- "+ Add field" button in toolbar
|
||||||
|
|
||||||
|
**Dialogs opened from here:** field edit/create, confirm delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
**Layout:** Toolbar + card list
|
||||||
|
|
||||||
|
- Cards show: name, formatter JS code (read-only `<pre>`), edit/delete actions
|
||||||
|
- "+ Add template" button
|
||||||
|
- Live preview panel in the edit dialog
|
||||||
|
|
||||||
|
**Dialogs opened from here:** template edit/create, confirm delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Grids
|
||||||
|
**Layout:** Tabbed, plus three full-page sub-views (not dialogs)
|
||||||
|
|
||||||
|
**Tab: Grids**
|
||||||
|
- Grid cards (image grid preview, name, delete)
|
||||||
|
- Draft cards (unprocessed setups, badge "Draft")
|
||||||
|
- "+ New grid" button → wizard dialog
|
||||||
|
|
||||||
|
**Tab: Source images**
|
||||||
|
- Source image gallery (`t-source-card`)
|
||||||
|
- "+ Upload" button
|
||||||
|
|
||||||
|
**Sub-view: Panel manager** (replaces main content)
|
||||||
|
- Editable grid of panel slots — each slot is a photo region
|
||||||
|
- Header with grid name, Cancel / Process buttons
|
||||||
|
|
||||||
|
**Sub-view: Grid setup** (replaces main content)
|
||||||
|
- Canvas corner editor (`Grid_Setup` class) with pan/zoom
|
||||||
|
- Right panel: cell size info, action buttons, progress
|
||||||
|
|
||||||
|
**Sub-view: Grid viewer** (replaces main content)
|
||||||
|
- Full grid of cell thumbnails
|
||||||
|
- Click cell → cell inventory overlay (non-modal panel)
|
||||||
|
- Header with Edit panels / Delete actions
|
||||||
|
|
||||||
|
**Dialogs opened from here:** new grid wizard, source picker, confirm delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bins
|
||||||
|
**Layout:** Tabbed
|
||||||
|
|
||||||
|
**Tab: Bins**
|
||||||
|
- Gallery of `t-bin-card` items (processed image or "Not processed" indicator)
|
||||||
|
- Click Edit (✎) → bin editor dialog
|
||||||
|
|
||||||
|
**Tab: Source images**
|
||||||
|
- Filtered source gallery (uses includes 'bin')
|
||||||
|
- Each card has a "+ Bin" button to create a bin from that source
|
||||||
|
|
||||||
|
**Tab: Types**
|
||||||
|
- List of `t-bin-type-row` items (name, dimensions, description)
|
||||||
|
- Edit / delete per row
|
||||||
|
- "+ Add type" button
|
||||||
|
|
||||||
|
**Dialogs opened from here:** bin editor, bin type edit/create, bin content
|
||||||
|
add/edit, field create, confirm delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Images
|
||||||
|
**Layout:** Admin list
|
||||||
|
|
||||||
|
- One row per source image: thumbnail, filename, dimensions, uses checkboxes
|
||||||
|
(grid / bin, toggleable), delete button
|
||||||
|
- Purpose: correct mislabeled source images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Patterns
|
||||||
|
|
||||||
|
These are the recurring structural widgets across the app. Listing all instances
|
||||||
|
makes the taxonomy visible and suggests what a higher-level UI DSL would need to
|
||||||
|
express.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Split pane (master/detail)
|
||||||
|
A resizable two-column layout. Left = filterable list. Right = detail view of
|
||||||
|
selected item, or a placeholder.
|
||||||
|
|
||||||
|
| Instance | Left | Right |
|
||||||
|
|----------|------|-------|
|
||||||
|
| Components | Component list + search | Detail panel (fields, images, files, inventory) |
|
||||||
|
|
||||||
|
Currently only one instance. The pattern is general enough to reuse for grids,
|
||||||
|
bins, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tabbed section
|
||||||
|
A tab bar that switches between content panels within the same section. Tab state
|
||||||
|
can be reflected in the URL (`/bins/sources`, `/grids/sources`).
|
||||||
|
|
||||||
|
| Instance | Tabs |
|
||||||
|
|----------|------|
|
||||||
|
| Grids section | Grids \| Source images |
|
||||||
|
| Bins section | Bins \| Source images \| Types |
|
||||||
|
| Bin editor dialog | Fields \| Contents |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Table view
|
||||||
|
Toolbar (search/filter + action button) + `<table>` with rows and per-row
|
||||||
|
edit/delete icon buttons.
|
||||||
|
|
||||||
|
| Instance | Row content |
|
||||||
|
|----------|-------------|
|
||||||
|
| Inventory | Component, type badge, location ref, qty, notes |
|
||||||
|
| Fields | Name, unit, description |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Card gallery
|
||||||
|
A wrapping grid of cards. Each card has a visual element (image or preview),
|
||||||
|
a title, and action buttons.
|
||||||
|
|
||||||
|
| Instance | Card type | Visual |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| Grid list | `t-grid-card` | Multi-thumbnail preview |
|
||||||
|
| Bin gallery | `t-bin-card` | Processed image or placeholder |
|
||||||
|
| Source image gallery | `t-source-card` | Photo thumbnail |
|
||||||
|
| Template list | `t-template-card` | Formatter code `<pre>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List row with actions
|
||||||
|
A horizontal row with info spans on the left and icon buttons on the right. Used
|
||||||
|
in data-table rows and standalone list elements.
|
||||||
|
|
||||||
|
| Instance | Info shown |
|
||||||
|
|----------|------------|
|
||||||
|
| Inventory table rows | Component, type, location, qty, notes |
|
||||||
|
| Field table rows | Name, unit, description |
|
||||||
|
| Bin type rows | Name, dimensions, description |
|
||||||
|
| Bin content rows | Name/component, qty, notes |
|
||||||
|
| Image admin rows | Thumbnail, filename, dimensions, uses checkboxes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Field editor (dynamic rows)
|
||||||
|
A list of label + text input + remove button rows, plus an "add field" dropdown
|
||||||
|
and "New…" button. Shared across component edit, bin edit, and bin type edit via
|
||||||
|
`build_field_editor()`.
|
||||||
|
|
||||||
|
| Instance | Host |
|
||||||
|
|----------|------|
|
||||||
|
| Component edit dialog | `#c-field-rows` |
|
||||||
|
| Bin editor — Fields tab | `#bin-field-rows` |
|
||||||
|
| Bin type dialog | `#bt-field-rows` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modal dialog
|
||||||
|
`<dialog>` element shown with `.showModal()`. All injected at init from
|
||||||
|
`<template>` elements. Closed via Cancel button, form submit, or backdrop click.
|
||||||
|
Save callback stored as a module-level variable, called by the registered submit
|
||||||
|
handler.
|
||||||
|
|
||||||
|
| Dialog | Purpose | Key inputs |
|
||||||
|
|--------|---------|------------|
|
||||||
|
| `dialog-component` | Create/edit component | Name, description, field editor |
|
||||||
|
| `dialog-inventory` | Create/edit inventory entry | Component, type, location, qty, notes, grid picker |
|
||||||
|
| `dialog-field` | Create/edit field definition | Name, unit, description |
|
||||||
|
| `dialog-template` | Create/edit name formatter | Name, JS code, test data, live preview |
|
||||||
|
| `dialog-new-grid` | Grid creation wizard | Name, rows, cols, photo coverage |
|
||||||
|
| `dialog-source-picker` | Pick a source image | Upload or select from gallery |
|
||||||
|
| `dialog-confirm` | Generic confirm/delete | Dynamic message text |
|
||||||
|
| `dialog-file-picker` | Link a PDF to a component | PDF list, upload section |
|
||||||
|
| `dialog-bin-editor` | Edit bin (image, corners, fields, contents) | Name, type, image/canvas, tabs |
|
||||||
|
| `dialog-bin-type` | Create/edit bin type | Name, dimensions, description, field editor |
|
||||||
|
| `dialog-bin-content` | Add/edit bin content item | Type (component/free text), qty, notes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Non-modal overlay / panel
|
||||||
|
Injected into the DOM and shown/hidden with `hidden`. Not a `<dialog>` — does not
|
||||||
|
block the rest of the UI.
|
||||||
|
|
||||||
|
| Instance | Trigger | Content |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| Cell inventory overlay | Click grid cell in viewer | Inventory entries for that cell, + add button |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Full-page sub-view (section replacement)
|
||||||
|
Replaces the entire `<main>` content. Not a dialog or overlay — the user is
|
||||||
|
"inside" a different mode of the same section. Back navigation returns to the
|
||||||
|
section list view.
|
||||||
|
|
||||||
|
| Instance | Entered from | Content |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| Grid setup | New grid wizard → source picker | Canvas corner editor + controls |
|
||||||
|
| Panel manager | Grid card or setup → next | Editable panel slot grid |
|
||||||
|
| Grid viewer | Grid card click | Cell thumbnail grid, cell inventory overlay |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Canvas editor
|
||||||
|
`Grid_Setup` class renders into a `<canvas>`. Handles pan (middle mouse), zoom
|
||||||
|
(wheel), and corner/edge handle dragging for perspective correction. Used in two
|
||||||
|
places with identical behaviour.
|
||||||
|
|
||||||
|
| Instance | Embedded in |
|
||||||
|
|----------|-------------|
|
||||||
|
| Grid setup sub-view | `t-grid-setup` canvas |
|
||||||
|
| Bin corner editor | `dialog-bin-editor` (revealed on "Adjust corners") |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Image lightbox
|
||||||
|
Full-screen image overlay triggered by clicking thumbnails. Single global
|
||||||
|
instance, not per-section. Clicking anywhere closes it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Template Inventory
|
||||||
|
|
||||||
|
| Template ID | Type | Renders |
|
||||||
|
|-------------|------|---------|
|
||||||
|
| `t-section-components` | Section | Split-pane container |
|
||||||
|
| `t-section-inventory` | Section | Table container |
|
||||||
|
| `t-section-fields` | Section | Table container |
|
||||||
|
| `t-section-templates` | Section | Card list container |
|
||||||
|
| `t-section-grids` | Section | Tabbed container |
|
||||||
|
| `t-section-bins` | Section | Tabbed container |
|
||||||
|
| `t-section-images` | Section | Admin list container |
|
||||||
|
| `t-component-row` | List item | Name + field-value badge tags |
|
||||||
|
| `t-field-tag` | Badge | Single field name + value |
|
||||||
|
| `t-detail-placeholder` | Placeholder | "Select a component" message |
|
||||||
|
| `t-detail-content` | Detail panel | Full component detail |
|
||||||
|
| `t-detail-field-row` | Row | Field name + rendered value |
|
||||||
|
| `t-detail-inv-entry` | Row | Inventory entry with images sub-gallery |
|
||||||
|
| `t-image-thumb` | Thumbnail | Image link + delete button |
|
||||||
|
| `t-inventory-row` | Table row | Inventory entry (all columns) |
|
||||||
|
| `t-field-row` | Table row | Field definition (all columns) |
|
||||||
|
| `t-empty-row` | Table empty | colspan message cell |
|
||||||
|
| `t-empty-block` | Block empty | Div with message |
|
||||||
|
| `t-template-card` | Card | Template name + code + actions |
|
||||||
|
| `t-source-card` | Card | Source image + meta + uses badges |
|
||||||
|
| `t-draft-card` | Card | Draft grid + badge + actions |
|
||||||
|
| `t-grid-card` | Card | Grid preview thumbs + meta + actions |
|
||||||
|
| `t-panel-manager` | Sub-view | Panel slot editor grid |
|
||||||
|
| `t-panel-slot` | Grid cell | Slot thumbnail + label |
|
||||||
|
| `t-grid-setup` | Sub-view | Canvas + controls |
|
||||||
|
| `t-grid-viewer` | Sub-view | Cell thumbnail grid |
|
||||||
|
| `t-grid-cell` | Grid cell | Cell image + location label |
|
||||||
|
| `t-bin-card` | Card | Bin image or placeholder + actions |
|
||||||
|
| `t-bin-type-row` | Row | Bin type info + actions |
|
||||||
|
| `t-bin-content-row` | Row | Content item info + actions |
|
||||||
|
| `t-img-admin-row` | Row | Source image admin row |
|
||||||
|
| `t-dialog-component` | Dialog | Component create/edit |
|
||||||
|
| `t-dialog-inventory` | Dialog | Inventory entry create/edit |
|
||||||
|
| `t-dialog-field` | Dialog | Field definition create/edit |
|
||||||
|
| `t-dialog-template` | Dialog | Template create/edit + preview |
|
||||||
|
| `t-dialog-new-grid` | Dialog | Grid creation wizard |
|
||||||
|
| `t-dialog-source-picker` | Dialog | Source image selector |
|
||||||
|
| `t-dialog-confirm` | Dialog | Generic confirm |
|
||||||
|
| `t-dialog-file-picker` | Dialog | PDF picker + upload |
|
||||||
|
| `t-dialog-bin-editor` | Dialog | Bin edit (image, corners, fields, contents) |
|
||||||
|
| `t-dialog-bin-type` | Dialog | Bin type create/edit |
|
||||||
|
| `t-dialog-bin-content` | Dialog | Bin content item create/edit |
|
||||||
|
| `t-cell-inventory` | Overlay | Grid cell inventory entries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for a Higher-Level UI Representation
|
||||||
|
|
||||||
|
The widgets above map fairly cleanly onto a small set of primitives that a UI DSL
|
||||||
|
would need:
|
||||||
|
|
||||||
|
- **Section** — a top-level navigable view, registered in the nav bar
|
||||||
|
- **Tabs** — switch between named content panels, state optionally in URL
|
||||||
|
- **SplitPane** — resizable master/detail, left = list, right = detail
|
||||||
|
- **Table** — toolbar + rows, each row has a schema and action set
|
||||||
|
- **Gallery** — wrapping grid of cards, each card has a schema
|
||||||
|
- **Row** — horizontal item with info fields and icon actions
|
||||||
|
- **FieldEditor** — dynamic key/value input list (add/remove/edit)
|
||||||
|
- **Dialog** — modal form with a save callback, injected from a template
|
||||||
|
- **Overlay** — non-modal panel, shown/hidden in place
|
||||||
|
- **SubView** — full-page mode that replaces main content
|
||||||
|
- **Canvas** — bespoke interactive widget (not expressible declaratively)
|
||||||
|
- **Lightbox** — global full-screen image viewer
|
||||||
|
|
||||||
|
Most sections are composed of 2–3 of these primitives. The Components section
|
||||||
|
(SplitPane → Table + Gallery + FieldEditor) is the most complex. The canvas-based
|
||||||
|
views are the only things that require imperative escape hatches.
|
||||||
Reference in New Issue
Block a user