Add grid image system with multi-panel support and SPA routing
- Grid images: photograph component boxes in sub-sections, assemble into one logical grid via perspective warp (homography) - Source image gallery: bulk upload photos separately from grid setup - Grid drafts: persist partial work, resume across sessions - Multi-panel grids: define rows/cols per photo, system computes panel layout; process partially configured grids, edit individual panels - Pan/zoom canvas editor (HiDPI-aware, touch support) for corner alignment - SPA routing with canonical URLs (history.pushState, server catch-all) - Express error visibility: uncaughtException/unhandledRejection handlers and 4-arg error middleware - Original filename stored on source image upload - Various null-safety fixes and CSS [hidden] override fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
package-lock.json
|
||||
|
||||
169
lib/grid-image.mjs
Normal file
169
lib/grid-image.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
import sharp from 'sharp';
|
||||
import { join } from 'node:path';
|
||||
import { generate_id } from './ids.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Homography math
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Solve 8x8 linear system Ax=b via Gaussian elimination with partial pivoting
|
||||
function gaussian_solve(A, b) {
|
||||
const n = A.length;
|
||||
const M = A.map((row, i) => [...row, b[i]]);
|
||||
for (let col = 0; col < n; col++) {
|
||||
let max_row = col;
|
||||
for (let row = col + 1; row < n; row++) {
|
||||
if (Math.abs(M[row][col]) > Math.abs(M[max_row][col])) max_row = row;
|
||||
}
|
||||
[M[col], M[max_row]] = [M[max_row], M[col]];
|
||||
for (let row = col + 1; row < n; row++) {
|
||||
const f = M[row][col] / M[col][col];
|
||||
for (let j = col; j <= n; j++) M[row][j] -= f * M[col][j];
|
||||
}
|
||||
}
|
||||
const x = new Array(n).fill(0);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
x[i] = M[i][n];
|
||||
for (let j = i + 1; j < n; j++) x[i] -= M[i][j] * x[j];
|
||||
x[i] /= M[i][i];
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
// Compute 3x3 homography H such that src_pts[i] -> dst_pts[i]
|
||||
// Points: [{x, y}] x4, order TL TR BR BL
|
||||
function compute_homography(src_pts, dst_pts) {
|
||||
const A = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const { x: sx, y: sy } = src_pts[i];
|
||||
const { x: dx, y: dy } = dst_pts[i];
|
||||
A.push([-sx, -sy, -1, 0, 0, 0, dx * sx, dx * sy, dx]);
|
||||
A.push([ 0, 0, 0, -sx, -sy, -1, dy * sx, dy * sy, dy]);
|
||||
}
|
||||
const M8 = A.map(row => row.slice(0, 8));
|
||||
const b = A.map(row => -row[8]);
|
||||
const h = gaussian_solve(M8, b);
|
||||
return [
|
||||
[h[0], h[1], h[2]],
|
||||
[h[3], h[4], h[5]],
|
||||
[h[6], h[7], 1.0 ],
|
||||
];
|
||||
}
|
||||
|
||||
// Invert a 3x3 matrix
|
||||
function invert_3x3(m) {
|
||||
const [[a, b, c], [d, e, f], [g, h, k]] = m;
|
||||
const det = a*(e*k - f*h) - b*(d*k - f*g) + c*(d*h - e*g);
|
||||
if (Math.abs(det) < 1e-10) throw new Error('Singular homography matrix');
|
||||
const s = 1 / det;
|
||||
return [
|
||||
[(e*k - f*h)*s, (c*h - b*k)*s, (b*f - c*e)*s],
|
||||
[(f*g - d*k)*s, (a*k - c*g)*s, (c*d - a*f)*s],
|
||||
[(d*h - e*g)*s, (b*g - a*h)*s, (a*e - b*d)*s],
|
||||
];
|
||||
}
|
||||
|
||||
// Bilinear sample from RGBA pixel buffer (Uint8Array, row-major)
|
||||
function bilinear_sample(pixels, width, height, x, y, out, out_idx) {
|
||||
const x0 = x | 0, y0 = y | 0;
|
||||
const tx = x - x0, ty = y - y0;
|
||||
|
||||
function px(xi, yi, ch) {
|
||||
if (xi < 0 || xi >= width || yi < 0 || yi >= height) return 0;
|
||||
return pixels[(yi * width + xi) * 4 + ch];
|
||||
}
|
||||
|
||||
for (let ch = 0; ch < 4; ch++) {
|
||||
const v00 = px(x0, y0, ch);
|
||||
const v10 = px(x0+1, y0, ch);
|
||||
const v01 = px(x0, y0+1, ch);
|
||||
const v11 = px(x0+1, y0+1, ch);
|
||||
out[out_idx + ch] = (v00 + (v10 - v00)*tx + (v01 - v00)*ty + (v11 - v10 - v01 + v00)*tx*ty) + 0.5 | 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Compute natural cell size from corner quadrilateral + grid dimensions
|
||||
export function compute_cell_size(corners, rows, cols) {
|
||||
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_cw = Math.max(top_w, bot_w) / cols;
|
||||
const raw_ch = Math.max(left_h, rgt_h) / rows;
|
||||
return {
|
||||
cell_w: Math.round(Math.min(480, Math.max(48, raw_cw))),
|
||||
cell_h: Math.round(Math.min(480, Math.max(48, raw_ch))),
|
||||
};
|
||||
}
|
||||
|
||||
// Process a source image: apply perspective warp, slice into cells, save to images_dir.
|
||||
// Returns 2D array cells[row][col] = filename.
|
||||
export async function process_grid_image(source_path, corners, rows, cols, cell_w, cell_h, images_dir) {
|
||||
const out_w = cols * cell_w;
|
||||
const out_h = rows * cell_h;
|
||||
|
||||
// H maps source corners -> output rectangle
|
||||
const dst_pts = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: out_w, y: 0 },
|
||||
{ x: out_w, y: out_h },
|
||||
{ x: 0, y: out_h },
|
||||
];
|
||||
|
||||
// Optionally downscale source to at most 2x the output resolution for speed
|
||||
const meta = await sharp(source_path).metadata();
|
||||
const max_src = Math.max(out_w, out_h) * 2;
|
||||
const src_scale = Math.min(1.0, max_src / Math.max(meta.width, meta.height));
|
||||
const scaled_src_w = Math.round(meta.width * src_scale);
|
||||
const scaled_src_h = Math.round(meta.height * src_scale);
|
||||
|
||||
const pipeline = src_scale < 0.99
|
||||
? sharp(source_path).resize(scaled_src_w, scaled_src_h)
|
||||
: sharp(source_path);
|
||||
|
||||
const { data: src_px } = await pipeline.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
|
||||
// Scale corner coords if we downscaled the source
|
||||
const sc = src_scale;
|
||||
const sc_corners = corners.map(c => ({ x: c.x * sc, y: c.y * sc }));
|
||||
const H_inv2 = invert_3x3(compute_homography(sc_corners, dst_pts));
|
||||
|
||||
// Warp full output grid
|
||||
const out_px = new Uint8Array(out_w * out_h * 4);
|
||||
const [hi0, hi1, hi2] = H_inv2;
|
||||
for (let oy = 0; oy < out_h; oy++) {
|
||||
for (let ox = 0; ox < out_w; ox++) {
|
||||
const denom = hi2[0]*ox + hi2[1]*oy + hi2[2];
|
||||
const sx = (hi0[0]*ox + hi0[1]*oy + hi0[2]) / denom;
|
||||
const sy = (hi1[0]*ox + hi1[1]*oy + hi1[2]) / denom;
|
||||
bilinear_sample(src_px, scaled_src_w, scaled_src_h, sx, sy, out_px, (oy*out_w + ox)*4);
|
||||
}
|
||||
}
|
||||
|
||||
// Slice into cells and save as JPEG
|
||||
const cells = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const row_arr = [];
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const filename = generate_id() + '.jpg';
|
||||
const cell_buf = Buffer.alloc(cell_w * cell_h * 4);
|
||||
const ox0 = col * cell_w, oy0 = row * cell_h;
|
||||
for (let cy = 0; cy < cell_h; cy++) {
|
||||
const src_row_off = ((oy0 + cy) * out_w + ox0) * 4;
|
||||
const dst_row_off = cy * cell_w * 4;
|
||||
cell_buf.set(out_px.subarray(src_row_off, src_row_off + cell_w * 4), dst_row_off);
|
||||
}
|
||||
await sharp(cell_buf, { raw: { width: cell_w, height: cell_h, channels: 4 } })
|
||||
.jpeg({ quality: 88 })
|
||||
.toFile(join(images_dir, filename));
|
||||
row_arr.push(filename);
|
||||
}
|
||||
cells.push(row_arr);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
@@ -80,3 +80,69 @@ export function set_inventory_entry(entry) {
|
||||
export function delete_inventory_entry(id) {
|
||||
return store.delete(`i:${id}`);
|
||||
}
|
||||
|
||||
// --- Grid drafts ---
|
||||
|
||||
export function list_grid_drafts() {
|
||||
const result = [];
|
||||
for (const [key] of store.data.entries()) {
|
||||
if (key.startsWith('d:')) result.push(store.get(key));
|
||||
}
|
||||
return result.sort((a, b) => b.updated_at - a.updated_at);
|
||||
}
|
||||
|
||||
export function get_grid_draft(id) {
|
||||
return store.get(`d:${id}`) ?? null;
|
||||
}
|
||||
|
||||
export function set_grid_draft(draft) {
|
||||
store.set(`d:${draft.id}`, draft);
|
||||
}
|
||||
|
||||
export function delete_grid_draft(id) {
|
||||
return store.delete(`d:${id}`);
|
||||
}
|
||||
|
||||
// --- Source images ---
|
||||
|
||||
export function list_source_images() {
|
||||
const result = [];
|
||||
for (const [key] of store.data.entries()) {
|
||||
if (key.startsWith('s:')) result.push(store.get(key));
|
||||
}
|
||||
return result.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
export function get_source_image(id) {
|
||||
return store.get(`s:${id}`) ?? null;
|
||||
}
|
||||
|
||||
export function add_source_image(src) {
|
||||
store.set(`s:${src.id}`, src);
|
||||
}
|
||||
|
||||
export function delete_source_image(id) {
|
||||
return store.delete(`s:${id}`);
|
||||
}
|
||||
|
||||
// --- Grid images ---
|
||||
|
||||
export function list_grid_images() {
|
||||
const result = [];
|
||||
for (const [key] of store.data.entries()) {
|
||||
if (key.startsWith('g:')) result.push(store.get(key));
|
||||
}
|
||||
return result.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
export function get_grid_image(id) {
|
||||
return store.get(`g:${id}`) ?? null;
|
||||
}
|
||||
|
||||
export function set_grid_image(grid) {
|
||||
store.set(`g:${grid.id}`, grid);
|
||||
}
|
||||
|
||||
export function delete_grid_image(id) {
|
||||
return store.delete(`g:${id}`);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
"name": "electronics-inventory",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": { "node": ">=25" },
|
||||
"engines": {
|
||||
"node": ">=25"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
956
public/app.mjs
956
public/app.mjs
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Electronics Inventory</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="preload" as="fetch" href="/templates.html" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,9 +14,10 @@
|
||||
<button class="nav-btn active" data-section="components">Components</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="grids">Grids</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="main"></main>
|
||||
<script type="module" src="app.mjs"></script>
|
||||
<script type="module" src="/app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -27,3 +27,19 @@ export const get_inventory = () => req('GET', '/api/inventory');
|
||||
export const create_inventory = (body) => req('POST', '/api/inventory', body);
|
||||
export const update_inventory = (id, body) => req('PUT', `/api/inventory/${id}`, body);
|
||||
export const delete_inventory = (id) => req('DELETE', `/api/inventory/${id}`);
|
||||
|
||||
// Grid drafts
|
||||
export const get_grid_drafts = () => req('GET', '/api/grid-drafts');
|
||||
export const create_grid_draft = (body) => req('POST', '/api/grid-drafts', body);
|
||||
export const update_grid_draft = (id, body) => req('PUT', `/api/grid-drafts/${id}`, body);
|
||||
export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`);
|
||||
|
||||
// Source images
|
||||
export const get_source_images = () => req('GET', '/api/source-images');
|
||||
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
|
||||
|
||||
// Grid images
|
||||
export const get_grids = () => req('GET', '/api/grid-images');
|
||||
export const get_grid = (id) => req('GET', `/api/grid-images/${id}`);
|
||||
export const delete_grid = (id) => req('DELETE', `/api/grid-images/${id}`);
|
||||
export const update_grid_panel = (id, pi, body) => req('PUT', `/api/grid-images/${id}/panels/${pi}`, body);
|
||||
|
||||
941
public/style.css
941
public/style.css
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,21 @@
|
||||
<!-- ===== COMPONENTS SECTION ===== -->
|
||||
<template id="t-section-components">
|
||||
<section class="section" id="section-components">
|
||||
<div class="section-toolbar">
|
||||
<input type="search" id="component-search" class="search-input" placeholder="Search components…">
|
||||
<button class="btn btn-primary" id="btn-add-component">+ Add component</button>
|
||||
<div class="split-layout">
|
||||
<div class="list-pane">
|
||||
<input type="search" id="component-search" class="search-input" placeholder="Search…">
|
||||
<input type="text" id="quick-add" class="quick-add-input" placeholder="New component… ↵" autocomplete="off" spellcheck="false">
|
||||
<div id="component-list" class="component-list"></div>
|
||||
</div>
|
||||
<div class="detail-pane" id="detail-pane"></div>
|
||||
</div>
|
||||
<div id="component-list" class="item-list"></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="t-component-row">
|
||||
<div class="component-row">
|
||||
<div class="component-main">
|
||||
<div class="component-header">
|
||||
<span class="component-name"></span>
|
||||
<span class="component-description"></span>
|
||||
</div>
|
||||
<div class="component-fields"></div>
|
||||
<div class="component-locations"></div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn-icon btn-edit" title="Edit">✎</button>
|
||||
<button class="btn-icon btn-danger btn-delete" title="Delete">✕</button>
|
||||
</div>
|
||||
<div class="component-row" tabindex="0">
|
||||
<div class="component-name"></div>
|
||||
<div class="component-tags"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,8 +23,86 @@
|
||||
<span class="field-tag"><span class="tag-name"></span><span class="tag-value"></span></span>
|
||||
</template>
|
||||
|
||||
<template id="t-location-badge">
|
||||
<span class="location-badge"><span class="badge-icon"></span><span class="badge-text"></span></span>
|
||||
<!-- ===== DETAIL PANEL ===== -->
|
||||
<template id="t-detail-placeholder">
|
||||
<div class="detail-placeholder">Select a component to view details</div>
|
||||
</template>
|
||||
|
||||
<template id="t-detail-content">
|
||||
<div class="detail-content">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2 class="detail-name"></h2>
|
||||
<p class="detail-description"></p>
|
||||
</div>
|
||||
<div class="detail-header-actions">
|
||||
<button class="btn btn-secondary detail-edit-btn">Edit</button>
|
||||
<button class="btn btn-danger detail-delete-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="detail-block-label">Fields</div>
|
||||
<div class="detail-fields-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="detail-block-label">Images</div>
|
||||
<div class="detail-images-row">
|
||||
<div class="image-grid comp-image-grid"></div>
|
||||
<label class="btn-add-image">
|
||||
<input type="file" accept="image/*" multiple hidden class="comp-img-input">
|
||||
+ Add image
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<div class="detail-block-label">
|
||||
Inventory
|
||||
<button class="btn btn-secondary detail-add-inv-btn">+ Add entry</button>
|
||||
</div>
|
||||
<div class="detail-inventory-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-detail-field-row">
|
||||
<div class="detail-field-row">
|
||||
<span class="detail-field-name"></span>
|
||||
<span class="detail-field-value"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-detail-inv-entry">
|
||||
<div class="detail-inv-entry">
|
||||
<div class="detail-inv-header">
|
||||
<span class="detail-inv-type"></span>
|
||||
<span class="detail-inv-ref"></span>
|
||||
<span class="detail-inv-qty"></span>
|
||||
<span class="detail-inv-notes"></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>
|
||||
<div class="detail-inv-images">
|
||||
<div class="image-grid inv-image-grid"></div>
|
||||
<label class="btn-add-image btn-add-image-sm">
|
||||
<input type="file" accept="image/*" multiple hidden class="inv-img-input">
|
||||
+ Image
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-image-thumb">
|
||||
<div class="image-thumb">
|
||||
<a class="thumb-link" target="_blank" rel="noopener">
|
||||
<img class="thumb-img" alt="">
|
||||
</a>
|
||||
<button type="button" class="thumb-delete btn-icon btn-danger" title="Remove">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== INVENTORY SECTION ===== -->
|
||||
@@ -47,30 +118,32 @@
|
||||
</select>
|
||||
<button class="btn btn-primary" id="btn-add-inventory">+ Add entry</button>
|
||||
</div>
|
||||
<div class="table-header inventory-grid">
|
||||
<span>Component</span>
|
||||
<span>Type</span>
|
||||
<span>Location / Reference</span>
|
||||
<span>Qty</span>
|
||||
<span>Notes</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div id="inventory-list" class="item-list"></div>
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Component</th>
|
||||
<th class="col-type">Type</th>
|
||||
<th>Location / Reference</th>
|
||||
<th class="col-qty">Qty</th>
|
||||
<th>Notes</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr></thead>
|
||||
<tbody id="inventory-list"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="t-inventory-row">
|
||||
<div class="inventory-row inventory-grid">
|
||||
<span class="inv-component-name"></span>
|
||||
<span class="inv-type-badge"></span>
|
||||
<span class="inv-location-ref"></span>
|
||||
<span class="inv-quantity"></span>
|
||||
<span class="inv-notes"></span>
|
||||
<span class="row-actions">
|
||||
<tr class="data-row">
|
||||
<td class="inv-component-name"></td>
|
||||
<td class="inv-type-badge"></td>
|
||||
<td class="inv-location-ref"></td>
|
||||
<td class="inv-quantity"></td>
|
||||
<td class="inv-notes"></td>
|
||||
<td 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>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- ===== FIELDS SECTION ===== -->
|
||||
@@ -80,25 +153,169 @@
|
||||
<span class="section-note">Master field index — fields available for all components</span>
|
||||
<button class="btn btn-primary" id="btn-add-field">+ Add field</button>
|
||||
</div>
|
||||
<div class="table-header fields-grid">
|
||||
<span>Field name</span>
|
||||
<span>Unit</span>
|
||||
<span>Description</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div id="field-list" class="item-list"></div>
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Field name</th>
|
||||
<th class="col-unit">Unit</th>
|
||||
<th>Description</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr></thead>
|
||||
<tbody id="field-list"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="t-field-row">
|
||||
<div class="field-def-row fields-grid">
|
||||
<span class="fdef-name"></span>
|
||||
<span class="fdef-unit"></span>
|
||||
<span class="fdef-description"></span>
|
||||
<span class="row-actions">
|
||||
<tr class="data-row">
|
||||
<td class="fdef-name"></td>
|
||||
<td class="fdef-unit"></td>
|
||||
<td class="fdef-description"></td>
|
||||
<td class="row-actions">
|
||||
<button class="btn-icon btn-edit" title="Edit">✎</button>
|
||||
<button class="btn-icon btn-danger btn-delete" title="Delete">✕</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- ===== EMPTY STATES ===== -->
|
||||
<template id="t-empty-row">
|
||||
<tr><td class="empty-state"></td></tr>
|
||||
</template>
|
||||
<template id="t-empty-block">
|
||||
<div class="empty-state"></div>
|
||||
</template>
|
||||
|
||||
<!-- ===== GRIDS SECTION ===== -->
|
||||
<template id="t-section-grids">
|
||||
<section class="section" id="section-grids">
|
||||
<div class="section-toolbar">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn" id="btn-tab-grids">Grids</button>
|
||||
<button class="tab-btn" id="btn-tab-sources">Source images</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-new-grid">+ New grid</button>
|
||||
<label class="btn btn-secondary" id="btn-upload-sources">
|
||||
+ Upload
|
||||
<input type="file" accept="image/*" multiple hidden id="source-upload-input">
|
||||
</label>
|
||||
</div>
|
||||
<div id="tab-grids-content">
|
||||
<div id="grid-list" class="grid-card-list"></div>
|
||||
</div>
|
||||
<div id="tab-sources-content" hidden>
|
||||
<div id="source-image-list" class="source-gallery"></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="t-source-card">
|
||||
<div class="source-card">
|
||||
<a class="source-card-link" target="_blank" rel="noopener">
|
||||
<img class="source-card-img" alt="">
|
||||
</a>
|
||||
<div class="source-card-meta"></div>
|
||||
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-draft-card">
|
||||
<div class="draft-card">
|
||||
<div class="draft-badge">Draft</div>
|
||||
<div class="draft-card-info">
|
||||
<div class="draft-card-name"></div>
|
||||
<div class="draft-card-meta"></div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn-icon btn-danger btn-delete" title="Discard">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-grid-card">
|
||||
<div class="grid-card">
|
||||
<div class="grid-card-preview"></div>
|
||||
<div class="grid-card-info">
|
||||
<div class="grid-card-name"></div>
|
||||
<div class="grid-card-meta"></div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn-icon btn-danger btn-delete" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== PANEL MANAGER VIEW ===== -->
|
||||
<template id="t-panel-manager">
|
||||
<div class="panel-manager" id="panel-manager">
|
||||
<div class="panel-manager-header">
|
||||
<div>
|
||||
<h2 class="pm-name"></h2>
|
||||
<div class="pm-meta"></div>
|
||||
</div>
|
||||
<div class="pm-actions">
|
||||
<button class="btn btn-secondary" id="pm-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="pm-process" disabled>Process all panels</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-slot-grid" id="panel-slot-grid"></div>
|
||||
<div class="setup-progress" id="pm-progress" hidden>Processing panels…</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-panel-slot">
|
||||
<div class="panel-slot" tabindex="0">
|
||||
<div class="panel-slot-preview">
|
||||
<img class="panel-slot-thumb" alt="" hidden>
|
||||
<div class="panel-slot-empty-icon"></div>
|
||||
</div>
|
||||
<div class="panel-slot-label"></div>
|
||||
<div class="panel-slot-range"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== GRID SETUP VIEW ===== -->
|
||||
<template id="t-grid-setup">
|
||||
<div class="grid-setup" id="grid-setup">
|
||||
<div class="grid-setup-left">
|
||||
<canvas id="grid-canvas" class="grid-canvas"></canvas>
|
||||
<div class="grid-setup-hint">Scroll to zoom · drag to pan · drag handles to align</div>
|
||||
</div>
|
||||
<div class="grid-setup-right">
|
||||
<div class="gs-panel-info" id="gs-panel-info"></div>
|
||||
<div class="setup-cell-size" id="gs-cell-size"></div>
|
||||
<div class="setup-actions">
|
||||
<button class="btn btn-secondary" id="gs-cancel">← Back</button>
|
||||
<button class="btn btn-primary" id="gs-confirm">Confirm panel</button>
|
||||
</div>
|
||||
<div class="setup-progress" id="gs-progress" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== GRID VIEWER ===== -->
|
||||
<template id="t-grid-viewer">
|
||||
<div class="grid-viewer" id="grid-viewer">
|
||||
<div class="grid-viewer-header">
|
||||
<div>
|
||||
<h2 class="viewer-name"></h2>
|
||||
<div class="viewer-meta"></div>
|
||||
</div>
|
||||
<div class="grid-viewer-actions">
|
||||
<button class="btn btn-secondary" id="gv-back">← Back</button>
|
||||
<button class="btn btn-secondary" id="gv-edit-panels">Edit panels</button>
|
||||
<button class="btn btn-danger" id="gv-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-cells" id="grid-cells"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-grid-cell">
|
||||
<div class="grid-cell">
|
||||
<div class="grid-cell-img-wrap">
|
||||
<img class="grid-cell-img" alt="">
|
||||
</div>
|
||||
<div class="grid-cell-label"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -194,6 +411,63 @@
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<!-- ===== DIALOG: NEW GRID ===== -->
|
||||
<template id="t-dialog-new-grid">
|
||||
<dialog id="dialog-new-grid" class="app-dialog">
|
||||
<h2 class="dialog-title">New grid</h2>
|
||||
<form method="dialog" id="form-new-grid">
|
||||
<div class="form-row">
|
||||
<label for="ng-name">Name</label>
|
||||
<input type="text" id="ng-name" autocomplete="off" placeholder="e.g. Capacitor box A">
|
||||
</div>
|
||||
<div class="form-row-pair">
|
||||
<div class="form-row">
|
||||
<label for="ng-rows">Total rows</label>
|
||||
<input type="number" id="ng-rows" min="1" max="500" value="4">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="ng-cols">Total columns</label>
|
||||
<input type="number" id="ng-cols" min="1" max="500" value="6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section-label">Photo coverage <span class="label-hint">(cells visible in each photo)</span></div>
|
||||
<div class="form-row-pair">
|
||||
<div class="form-row">
|
||||
<label for="ng-photo-rows">Rows per photo</label>
|
||||
<input type="number" id="ng-photo-rows" min="1" max="500" value="4">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="ng-photo-cols">Cols per photo</label>
|
||||
<input type="number" id="ng-photo-cols" min="1" max="500" value="6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-summary" id="ng-summary"></div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" id="ng-cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Continue →</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<!-- ===== DIALOG: SOURCE PICKER ===== -->
|
||||
<template id="t-dialog-source-picker">
|
||||
<dialog id="dialog-source-picker" class="app-dialog app-dialog-wide">
|
||||
<h2 class="dialog-title">Choose source image</h2>
|
||||
<div class="picker-toolbar">
|
||||
<label class="btn btn-secondary">
|
||||
+ Upload new
|
||||
<input type="file" accept="image/*" multiple hidden id="picker-upload-input">
|
||||
</label>
|
||||
<span class="picker-hint">or select an existing image below</span>
|
||||
</div>
|
||||
<div id="source-picker-grid" class="source-gallery picker-gallery"></div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" id="picker-cancel">Cancel</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<!-- ===== DIALOG: CONFIRM ===== -->
|
||||
<template id="t-dialog-confirm">
|
||||
<dialog id="dialog-confirm" class="app-dialog app-dialog-sm">
|
||||
|
||||
263
public/views/grid-setup.mjs
Normal file
263
public/views/grid-setup.mjs
Normal file
@@ -0,0 +1,263 @@
|
||||
// Grid setup canvas UI — handles corner selection, pan, and zoom
|
||||
|
||||
export class Grid_Setup {
|
||||
#canvas;
|
||||
#ctx;
|
||||
#img = null;
|
||||
#scale = 1; // image px → world px (CSS px at cam_z=1)
|
||||
#css_w = 0;
|
||||
#css_h = 0;
|
||||
|
||||
// Camera: world → screen (screen = world * cam_z + cam_offset)
|
||||
#cam_x = 0;
|
||||
#cam_y = 0;
|
||||
#cam_z = 1;
|
||||
|
||||
#corners = null; // in IMAGE coordinates
|
||||
#drag_idx = -1; // index of corner being dragged, or -1
|
||||
#panning = false;
|
||||
#pan_last = { x: 0, y: 0 };
|
||||
|
||||
#rows = 4;
|
||||
#cols = 6;
|
||||
|
||||
constructor(canvas_el) {
|
||||
this.#canvas = canvas_el;
|
||||
this.#ctx = canvas_el.getContext('2d');
|
||||
|
||||
canvas_el.addEventListener('mousedown', e => this.#on_down(e));
|
||||
canvas_el.addEventListener('mousemove', e => this.#on_move(e));
|
||||
canvas_el.addEventListener('mouseup', e => this.#on_up(e));
|
||||
canvas_el.addEventListener('mouseleave', () => { this.#drag_idx = -1; this.#panning = false; });
|
||||
canvas_el.addEventListener('wheel', e => this.#on_wheel(e), { passive: false });
|
||||
|
||||
canvas_el.addEventListener('touchstart', e => { e.preventDefault(); this.#on_down(e.touches[0], true); }, { passive: false });
|
||||
canvas_el.addEventListener('touchmove', e => { e.preventDefault(); this.#on_move(e.touches[0], true); }, { passive: false });
|
||||
canvas_el.addEventListener('touchend', () => { this.#drag_idx = -1; this.#panning = false; });
|
||||
}
|
||||
|
||||
load_image(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this.#img = img;
|
||||
const max_w = this.#canvas.parentElement.clientWidth || 800;
|
||||
const max_h = Math.floor(window.innerHeight * 0.65);
|
||||
|
||||
// Canvas fills the available space
|
||||
this.#css_w = max_w;
|
||||
this.#css_h = max_h;
|
||||
|
||||
// Scale: fit image within canvas with slight padding
|
||||
this.#scale = Math.min(
|
||||
(max_w * 0.9) / img.width,
|
||||
(max_h * 0.9) / img.height,
|
||||
);
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.#canvas.width = this.#css_w * dpr;
|
||||
this.#canvas.height = this.#css_h * dpr;
|
||||
this.#canvas.style.width = this.#css_w + 'px';
|
||||
this.#canvas.style.height = this.#css_h + 'px';
|
||||
this.#ctx.scale(dpr, dpr);
|
||||
|
||||
// Camera: start centered, image fitted within canvas
|
||||
const img_w = img.width * this.#scale;
|
||||
const img_h = img.height * this.#scale;
|
||||
this.#cam_z = 1;
|
||||
this.#cam_x = (max_w - img_w) / 2;
|
||||
this.#cam_y = (max_h - img_h) / 2;
|
||||
|
||||
// Default corners: 15% inset in image coords
|
||||
const mx = img.width * 0.15;
|
||||
const my = img.height * 0.15;
|
||||
this.#corners = [
|
||||
{ x: mx, y: my }, // TL
|
||||
{ x: img.width - mx, y: my }, // TR
|
||||
{ x: img.width - mx, y: img.height - my }, // BR
|
||||
{ x: mx, y: img.height - my }, // BL
|
||||
];
|
||||
this.#draw();
|
||||
resolve({ width: img.width, height: img.height });
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
set_rows(n) { this.#rows = n; this.#draw(); }
|
||||
set_cols(n) { this.#cols = n; this.#draw(); }
|
||||
|
||||
set_corners(corners) {
|
||||
if (!corners || corners.length !== 4) return;
|
||||
this.#corners = corners.map(c => ({ x: c.x, y: c.y }));
|
||||
this.#draw();
|
||||
}
|
||||
|
||||
get_corners() { return this.#corners?.map(c => ({ x: Math.round(c.x), y: Math.round(c.y) })); }
|
||||
|
||||
// Raw CSS pixel position on canvas
|
||||
#screen_pos(e) {
|
||||
const r = this.#canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
}
|
||||
|
||||
// Screen → world (CSS px at cam_z=1, i.e. the coordinate space image is drawn in)
|
||||
#to_world(sp) {
|
||||
return {
|
||||
x: (sp.x - this.#cam_x) / this.#cam_z,
|
||||
y: (sp.y - this.#cam_y) / this.#cam_z,
|
||||
};
|
||||
}
|
||||
|
||||
// Image coords → world coords
|
||||
#img_to_world(pt) {
|
||||
return { x: pt.x * this.#scale, y: pt.y * this.#scale };
|
||||
}
|
||||
|
||||
// World coords → image coords
|
||||
#world_to_img(pt) {
|
||||
return { x: pt.x / this.#scale, y: pt.y / this.#scale };
|
||||
}
|
||||
|
||||
// Image coords → screen coords (for hit testing)
|
||||
#img_to_screen(pt) {
|
||||
const w = this.#img_to_world(pt);
|
||||
return { x: w.x * this.#cam_z + this.#cam_x, y: w.y * this.#cam_z + this.#cam_y };
|
||||
}
|
||||
|
||||
#find_handle(sp, radius = 18) {
|
||||
if (!this.#corners) return -1;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const s = this.#img_to_screen(this.#corners[i]);
|
||||
if ((sp.x - s.x)**2 + (sp.y - s.y)**2 < radius**2) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
#on_down(e, is_touch = false) {
|
||||
if (!this.#corners) return;
|
||||
const sp = this.#screen_pos(e);
|
||||
const hit = this.#find_handle(sp);
|
||||
if (hit !== -1) {
|
||||
this.#drag_idx = hit;
|
||||
} else {
|
||||
this.#panning = true;
|
||||
this.#pan_last = sp;
|
||||
if (!is_touch) this.#canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
}
|
||||
|
||||
#on_move(e, is_touch = false) {
|
||||
const sp = this.#screen_pos(e);
|
||||
if (this.#drag_idx !== -1) {
|
||||
const img_pos = this.#world_to_img(this.#to_world(sp));
|
||||
this.#corners[this.#drag_idx] = {
|
||||
x: Math.max(0, Math.min(this.#img.width, img_pos.x)),
|
||||
y: Math.max(0, Math.min(this.#img.height, img_pos.y)),
|
||||
};
|
||||
this.#draw();
|
||||
} else if (this.#panning) {
|
||||
this.#cam_x += sp.x - this.#pan_last.x;
|
||||
this.#cam_y += sp.y - this.#pan_last.y;
|
||||
this.#pan_last = sp;
|
||||
this.#draw();
|
||||
} else if (!is_touch) {
|
||||
const cursor = this.#find_handle(sp) !== -1 ? 'grab' : 'default';
|
||||
this.#canvas.style.cursor = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
#on_up(e) {
|
||||
this.#drag_idx = -1;
|
||||
if (this.#panning) {
|
||||
this.#panning = false;
|
||||
const sp = this.#screen_pos(e);
|
||||
this.#canvas.style.cursor = this.#find_handle(sp) !== -1 ? 'grab' : 'default';
|
||||
}
|
||||
}
|
||||
|
||||
#on_wheel(e) {
|
||||
e.preventDefault();
|
||||
const sp = this.#screen_pos(e);
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const wx = (sp.x - this.#cam_x) / this.#cam_z;
|
||||
const wy = (sp.y - this.#cam_y) / this.#cam_z;
|
||||
this.#cam_z = Math.max(0.1, Math.min(20, this.#cam_z * factor));
|
||||
this.#cam_x = sp.x - wx * this.#cam_z;
|
||||
this.#cam_y = sp.y - wy * this.#cam_z;
|
||||
this.#draw();
|
||||
}
|
||||
|
||||
#draw() {
|
||||
const ctx = this.#ctx;
|
||||
if (!this.#img || !this.#corners) return;
|
||||
|
||||
const W = this.#css_w, H = this.#css_h;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#0e0e0e';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.#cam_x, this.#cam_y);
|
||||
ctx.scale(this.#cam_z, this.#cam_z);
|
||||
|
||||
// Image drawn in world space at its scaled dimensions
|
||||
const img_w = this.#img.width * this.#scale;
|
||||
const img_h = this.#img.height * this.#scale;
|
||||
ctx.drawImage(this.#img, 0, 0, img_w, img_h);
|
||||
|
||||
// Everything below is in world coords (image coords × #scale)
|
||||
const dp = this.#corners.map(c => this.#img_to_world(c));
|
||||
const rows = this.#rows, cols = this.#cols;
|
||||
|
||||
// Outline
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dp[0].x, dp[0].y);
|
||||
dp.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
|
||||
ctx.closePath();
|
||||
ctx.strokeStyle = 'rgba(91,156,246,0.9)';
|
||||
ctx.lineWidth = 2 / this.#cam_z;
|
||||
ctx.stroke();
|
||||
|
||||
// Grid lines
|
||||
function lerp(a, b, t) { return { x: a.x + (b.x-a.x)*t, y: a.y + (b.y-a.y)*t }; }
|
||||
|
||||
ctx.strokeStyle = 'rgba(91,156,246,0.45)';
|
||||
ctx.lineWidth = 1 / this.#cam_z;
|
||||
for (let r = 1; r < rows; r++) {
|
||||
const t = r / rows;
|
||||
const a = lerp(dp[0], dp[3], t), b = lerp(dp[1], dp[2], t);
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
}
|
||||
for (let c = 1; c < cols; c++) {
|
||||
const t = c / cols;
|
||||
const a = lerp(dp[0], dp[1], t), b = lerp(dp[3], dp[2], t);
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Corner handles — fixed screen size regardless of zoom
|
||||
const handle_r = 10 / this.#cam_z;
|
||||
const font_size = Math.round(9 / this.#cam_z);
|
||||
const COLORS = ['#f6a65b', '#f65b9c', '#5bf69c', '#5b9cf6'];
|
||||
const LABELS = ['TL', 'TR', 'BR', 'BL'];
|
||||
dp.forEach((pt, i) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, handle_r, 0, Math.PI*2);
|
||||
ctx.fillStyle = COLORS[i];
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.lineWidth = 2 / this.#cam_z;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `bold ${font_size}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(LABELS[i], pt.x, pt.y);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
286
server.mjs
286
server.mjs
@@ -1,20 +1,47 @@
|
||||
process.on('unhandledRejection', (reason) => { console.error('[unhandledRejection]', reason); });
|
||||
process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); process.exit(1); });
|
||||
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { unlinkSync, mkdirSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { generate_id } from './lib/ids.mjs';
|
||||
import { compute_cell_size, process_grid_image } from './lib/grid-image.mjs';
|
||||
import {
|
||||
list_fields, get_field, set_field, delete_field,
|
||||
list_components, get_component, set_component, delete_component,
|
||||
list_inventory, get_inventory_entry, set_inventory_entry, delete_inventory_entry,
|
||||
list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft,
|
||||
list_source_images, get_source_image, add_source_image, delete_source_image,
|
||||
list_grid_images, get_grid_image, set_grid_image, delete_grid_image,
|
||||
} from './lib/storage.mjs';
|
||||
|
||||
mkdirSync('./data/images', { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/img', express.static('./data/images'));
|
||||
app.use(express.static(new URL('./public/', import.meta.url).pathname));
|
||||
|
||||
const PORT = process.env.PORT ?? 3020;
|
||||
const BIND_ADDRESS = process.env.BIND_ADDRESS ?? 'localhost';
|
||||
|
||||
function ok(res, data = {}) { res.json({ ok: true, ...data }); }
|
||||
function fail(res, msg, status = 400) { res.status(status).json({ ok: false, error: msg }); }
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: './data/images',
|
||||
filename: (req, file, cb) => cb(null, generate_id() + extname(file.originalname).toLowerCase()),
|
||||
}),
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
function remove_image_file(img_id) {
|
||||
try { unlinkSync(join('./data/images', img_id)); } catch {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -71,6 +98,7 @@ app.post('/api/components', (req, res) => {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
fields,
|
||||
images: [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -101,6 +129,24 @@ app.delete('/api/components/:id', (req, res) => {
|
||||
ok(res);
|
||||
});
|
||||
|
||||
app.post('/api/components/:id/images', upload.array('images', 20), (req, res) => {
|
||||
const comp = get_component(req.params.id);
|
||||
if (!comp) return fail(res, 'not found', 404);
|
||||
const new_ids = req.files.map(f => f.filename);
|
||||
const updated = { ...comp, images: [...(comp.images ?? []), ...new_ids], updated_at: Date.now() };
|
||||
set_component(updated);
|
||||
ok(res, { component: updated });
|
||||
});
|
||||
|
||||
app.delete('/api/components/:id/images/:img_id', (req, res) => {
|
||||
const comp = get_component(req.params.id);
|
||||
if (!comp) return fail(res, 'not found', 404);
|
||||
const updated = { ...comp, images: (comp.images ?? []).filter(id => id !== req.params.img_id), updated_at: Date.now() };
|
||||
set_component(updated);
|
||||
remove_image_file(req.params.img_id);
|
||||
ok(res, { component: updated });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory entries
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -122,6 +168,7 @@ app.post('/api/inventory', (req, res) => {
|
||||
location_ref: String(location_ref).trim(),
|
||||
quantity: String(quantity).trim(),
|
||||
notes: String(notes).trim(),
|
||||
images: [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -147,6 +194,241 @@ app.delete('/api/inventory/:id', (req, res) => {
|
||||
ok(res);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Electronics Inventory running on http://localhost:${PORT}`);
|
||||
app.post('/api/inventory/:id/images', upload.array('images', 20), (req, res) => {
|
||||
const entry = get_inventory_entry(req.params.id);
|
||||
if (!entry) return fail(res, 'not found', 404);
|
||||
const new_ids = req.files.map(f => f.filename);
|
||||
const updated = { ...entry, images: [...(entry.images ?? []), ...new_ids], updated_at: Date.now() };
|
||||
set_inventory_entry(updated);
|
||||
ok(res, { entry: updated });
|
||||
});
|
||||
|
||||
app.delete('/api/inventory/:id/images/:img_id', (req, res) => {
|
||||
const entry = get_inventory_entry(req.params.id);
|
||||
if (!entry) return fail(res, 'not found', 404);
|
||||
const updated = { ...entry, images: (entry.images ?? []).filter(id => id !== req.params.img_id), updated_at: Date.now() };
|
||||
set_inventory_entry(updated);
|
||||
remove_image_file(req.params.img_id);
|
||||
ok(res, { entry: updated });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid drafts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/grid-drafts', (req, res) => {
|
||||
ok(res, { drafts: list_grid_drafts() });
|
||||
});
|
||||
|
||||
app.post('/api/grid-drafts', (req, res) => {
|
||||
const { name, rows, cols, panel_rows, panel_cols, panels } = req.body;
|
||||
if (!name || !rows || !cols || !panels) return fail(res, 'missing fields');
|
||||
const draft = {
|
||||
id: generate_id(),
|
||||
name, rows, cols, panel_rows, panel_cols, panels,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
set_grid_draft(draft);
|
||||
ok(res, { draft });
|
||||
});
|
||||
|
||||
app.put('/api/grid-drafts/:id', (req, res) => {
|
||||
const existing = get_grid_draft(req.params.id);
|
||||
if (!existing) return fail(res, 'not found', 404);
|
||||
const { name, rows, cols, panel_rows, panel_cols, panels } = req.body;
|
||||
const draft = { ...existing, name, rows, cols, panel_rows, panel_cols, panels, updated_at: Date.now() };
|
||||
set_grid_draft(draft);
|
||||
ok(res, { draft });
|
||||
});
|
||||
|
||||
app.delete('/api/grid-drafts/:id', (req, res) => {
|
||||
if (!get_grid_draft(req.params.id)) return fail(res, 'not found', 404);
|
||||
delete_grid_draft(req.params.id);
|
||||
ok(res);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source images (gallery for grid setup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/source-images', (req, res) => {
|
||||
ok(res, { sources: list_source_images() });
|
||||
});
|
||||
|
||||
app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
|
||||
if (!req.files?.length) return fail(res, 'no files');
|
||||
const added = [];
|
||||
for (const file of req.files) {
|
||||
const meta = await sharp(file.path).metadata();
|
||||
const src = {
|
||||
id: file.filename,
|
||||
original_name: file.originalname,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
add_source_image(src);
|
||||
added.push(src);
|
||||
}
|
||||
ok(res, { sources: added });
|
||||
});
|
||||
|
||||
app.delete('/api/source-images/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!get_source_image(id)) return fail(res, 'not found', 404);
|
||||
const grids = list_grid_images();
|
||||
const in_use = grids.find(g =>
|
||||
g.source_id === id ||
|
||||
(g.panels && g.panels.some(p => p.source_id === id))
|
||||
);
|
||||
if (in_use) return fail(res, `In use by grid "${in_use.name}"`, 409);
|
||||
remove_image_file(id);
|
||||
delete_source_image(id);
|
||||
ok(res);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function panel_dims(pi, panel_rows, panel_cols, rows, cols) {
|
||||
const pr = Math.floor(pi / panel_cols);
|
||||
const pc = pi % panel_cols;
|
||||
const base_rows = Math.floor(rows / panel_rows);
|
||||
const base_cols = Math.floor(cols / panel_cols);
|
||||
const p_rows = pr === panel_rows - 1 ? rows - pr * base_rows : base_rows;
|
||||
const p_cols = pc === panel_cols - 1 ? cols - pc * base_cols : base_cols;
|
||||
return { pr, pc, p_rows, p_cols, row_off: pr * base_rows, col_off: pc * base_cols };
|
||||
}
|
||||
|
||||
app.get('/api/grid-images', (req, res) => {
|
||||
ok(res, { grids: list_grid_images() });
|
||||
});
|
||||
|
||||
app.get('/api/grid-images/:id', (req, res) => {
|
||||
const grid = get_grid_image(req.params.id);
|
||||
if (!grid) return fail(res, 'not found', 404);
|
||||
ok(res, { grid });
|
||||
});
|
||||
|
||||
app.post('/api/grid-images', async (req, res) => {
|
||||
const { name = '', rows, cols, panel_rows = 1, panel_cols = 1, panels } = req.body;
|
||||
if (!rows || !cols || !panels?.length) return fail(res, 'missing fields');
|
||||
if (panels.length !== panel_rows * panel_cols) return fail(res, 'panel count mismatch');
|
||||
|
||||
const configured = panels.filter(p => p?.source_id && p?.corners?.length === 4);
|
||||
if (configured.length === 0) return fail(res, 'no configured panels');
|
||||
for (const p of configured) {
|
||||
if (!get_source_image(p.source_id)) return fail(res, `source ${p.source_id} not found`, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const full_cells = Array.from({ length: rows }, () => Array(cols).fill(null));
|
||||
let total_cell_w = 0, total_cell_h = 0, count = 0;
|
||||
|
||||
for (let pi = 0; pi < panels.length; pi++) {
|
||||
const p = panels[pi];
|
||||
if (!p?.source_id || !p?.corners) continue;
|
||||
const { p_rows, p_cols, row_off, col_off } =
|
||||
panel_dims(pi, panel_rows, panel_cols, rows, cols);
|
||||
|
||||
const source_path = join('./data/images', p.source_id);
|
||||
const { cell_w, cell_h } = compute_cell_size(p.corners, p_rows, p_cols);
|
||||
const panel_cells = await process_grid_image(
|
||||
source_path, p.corners, p_rows, p_cols, cell_w, cell_h, './data/images'
|
||||
);
|
||||
total_cell_w += cell_w; total_cell_h += cell_h; count++;
|
||||
for (let r = 0; r < p_rows; r++)
|
||||
for (let c = 0; c < p_cols; c++)
|
||||
full_cells[row_off + r][col_off + c] = panel_cells[r][c];
|
||||
}
|
||||
|
||||
const grid = {
|
||||
id: generate_id(),
|
||||
name: name.trim() || 'Grid',
|
||||
rows, cols, panel_rows, panel_cols,
|
||||
cell_w: Math.round(total_cell_w / count),
|
||||
cell_h: Math.round(total_cell_h / count),
|
||||
panels: panels.map(p => p?.source_id ? { source_id: p.source_id, corners: p.corners } : null),
|
||||
cells: full_cells,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
set_grid_image(grid);
|
||||
ok(res, { grid });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
fail(res, err.message, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-process a single panel of an existing grid
|
||||
app.put('/api/grid-images/:id/panels/:pi', async (req, res) => {
|
||||
const grid = get_grid_image(req.params.id);
|
||||
if (!grid) return fail(res, 'not found', 404);
|
||||
const pi = parseInt(req.params.pi);
|
||||
if (isNaN(pi) || pi < 0 || pi >= grid.panel_rows * grid.panel_cols) return fail(res, 'invalid panel index');
|
||||
const { source_id, corners } = req.body;
|
||||
if (!source_id || !corners || corners.length !== 4) return fail(res, 'missing fields');
|
||||
if (!get_source_image(source_id)) return fail(res, 'source image not found', 404);
|
||||
|
||||
try {
|
||||
const { p_rows, p_cols, row_off, col_off } =
|
||||
panel_dims(pi, grid.panel_rows, grid.panel_cols, grid.rows, grid.cols);
|
||||
|
||||
// Delete old cell images for this panel's region
|
||||
for (let r = 0; r < p_rows; r++)
|
||||
for (let c = 0; c < p_cols; c++)
|
||||
if (grid.cells[row_off + r]?.[col_off + c]) remove_image_file(grid.cells[row_off + r][col_off + c]);
|
||||
|
||||
const source_path = join('./data/images', source_id);
|
||||
const { cell_w, cell_h } = compute_cell_size(corners, p_rows, p_cols);
|
||||
const panel_cells = await process_grid_image(
|
||||
source_path, corners, p_rows, p_cols, cell_w, cell_h, './data/images'
|
||||
);
|
||||
|
||||
const updated_panels = [...(grid.panels ?? [])];
|
||||
updated_panels[pi] = { source_id, corners };
|
||||
const updated_cells = grid.cells.map(row => [...row]);
|
||||
for (let r = 0; r < p_rows; r++)
|
||||
for (let c = 0; c < p_cols; c++)
|
||||
updated_cells[row_off + r][col_off + c] = panel_cells[r][c];
|
||||
|
||||
const updated = { ...grid, panels: updated_panels, cells: updated_cells, updated_at: Date.now() };
|
||||
set_grid_image(updated);
|
||||
ok(res, { grid: updated });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
fail(res, err.message, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/grid-images/:id', (req, res) => {
|
||||
const grid = get_grid_image(req.params.id);
|
||||
if (!grid) return fail(res, 'not found', 404);
|
||||
for (const row of grid.cells) {
|
||||
for (const filename of row) {
|
||||
if (filename) remove_image_file(filename);
|
||||
}
|
||||
}
|
||||
// Source image is managed separately — do not delete it here
|
||||
delete_grid_image(grid.id);
|
||||
ok(res);
|
||||
});
|
||||
|
||||
// SPA fallback — serve index.html for any non-API, non-asset path
|
||||
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
|
||||
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));
|
||||
|
||||
// Express error handler — catches errors thrown/rejected in route handlers
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(`[express error] ${req.method} ${req.path}`, err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ ok: false, error: err.message ?? 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.listen(PORT, BIND_ADDRESS, () => {
|
||||
console.log(`Electronics Inventory running on http://${BIND_ADDRESS}:${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user