diff --git a/.gitignore b/.gitignore
index 902b281..a8ac7b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules/
data/
+package-lock.json
diff --git a/lib/grid-image.mjs b/lib/grid-image.mjs
new file mode 100644
index 0000000..d3755fe
--- /dev/null
+++ b/lib/grid-image.mjs
@@ -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;
+}
diff --git a/lib/storage.mjs b/lib/storage.mjs
index 88259d8..b7821d4 100644
--- a/lib/storage.mjs
+++ b/lib/storage.mjs
@@ -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}`);
+}
diff --git a/package.json b/package.json
index 88ad803..e036aaa 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/public/app.mjs b/public/app.mjs
index 101cd61..69f5a5f 100644
--- a/public/app.mjs
+++ b/public/app.mjs
@@ -1,5 +1,6 @@
-import { qs, clone, set_text } from './lib/dom.mjs';
+import { qs, clone, set_text, show, hide } from './lib/dom.mjs';
import * as api from './lib/api.mjs';
+import { Grid_Setup } from './views/grid-setup.mjs';
// ---------------------------------------------------------------------------
// State
@@ -12,20 +13,38 @@ let all_inventory = [];
let component_search = '';
let inventory_search = '';
let inventory_type_filter = '';
+let selected_component_id = null;
+let grid_view_state = 'list'; // 'list' | 'panels' | 'setup' | 'viewer'
+let grid_tab = 'grids'; // 'grids' | 'sources'
+let current_grid_id = null;
+let all_grids = [];
+let all_sources = [];
+let grid_setup_instance = null;
+let grid_source_id = null;
+// Draft grid being assembled from panels
+let grid_draft = null; // { id?, name, rows, cols, panel_rows, panel_cols, panels[], edit_grid_id? }
+let current_panel_idx = null;
+let all_drafts = [];
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function load_all() {
- const [cf, ci, cmp] = await Promise.all([
+ const [cf, ci, cmp, gr, dr, sr] = await Promise.all([
api.get_fields(),
api.get_inventory(),
api.get_components(),
+ api.get_grids(),
+ api.get_grid_drafts(),
+ api.get_source_images(),
]);
all_fields = cf.fields;
all_inventory = ci.entries;
all_components = cmp.components;
+ all_grids = gr.grids;
+ all_drafts = dr.drafts;
+ all_sources = sr.sources;
}
// ---------------------------------------------------------------------------
@@ -33,7 +52,6 @@ async function load_all() {
// ---------------------------------------------------------------------------
const LOCATION_TYPE_LABEL = { physical: 'Physical', bom: 'BOM', digital: 'Digital' };
-const LOCATION_TYPE_ICON = { physical: '📦', bom: '📋', digital: '💡' };
function ref_label_for_type(type) {
if (type === 'physical') return 'Location (drawer, bin, shelf…)';
@@ -61,7 +79,6 @@ function matches_search(component, query) {
for (const val of Object.values(component.fields ?? {})) {
if (String(val).toLowerCase().includes(q)) return true;
}
- // Also search inventory locations for this component
for (const entry of inventory_for_component(component.id)) {
if (entry.location_ref.toLowerCase().includes(q)) return true;
}
@@ -80,7 +97,36 @@ function matches_inventory_search(entry, query, type_filter) {
}
// ---------------------------------------------------------------------------
-// Render: Components section
+// Image upload helper
+// ---------------------------------------------------------------------------
+
+async function upload_images(files, url, on_done) {
+ if (!files.length) return;
+ const form = new FormData();
+ for (const f of files) form.append('images', f);
+ const res = await fetch(url, { method: 'POST', body: form });
+ const data = await res.json();
+ if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
+ on_done(data);
+}
+
+function build_image_grid(grid_el, images, on_delete) {
+ if (!images?.length) {
+ grid_el.replaceChildren();
+ return;
+ }
+ grid_el.replaceChildren(...images.map(img_id => {
+ const thumb = clone('t-image-thumb');
+ const link = qs(thumb, '.thumb-link');
+ link.href = `/img/${img_id}`;
+ qs(thumb, '.thumb-img').src = `/img/${img_id}`;
+ qs(thumb, '.thumb-delete').addEventListener('click', () => on_delete(img_id));
+ return thumb;
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// Render: Components section (split pane)
// ---------------------------------------------------------------------------
function render_components() {
@@ -95,11 +141,23 @@ function render_components() {
component_search = e.target.value;
render_component_list();
});
- qs(section_el, '#btn-add-component').addEventListener('click', () => open_component_dialog());
+ qs(section_el, '#quick-add').addEventListener('keydown', async (e) => {
+ if (e.key !== 'Enter') return;
+ const name = e.target.value.trim();
+ if (!name) return;
+ const result = await api.create_component({ name });
+ all_components.push(result.component);
+ all_components.sort((a, b) => a.name.localeCompare(b.name));
+ selected_component_id = result.component.id;
+ e.target.value = '';
+ render_component_list();
+ render_detail_panel();
+ });
}
qs(section_el, '#component-search').value = component_search;
render_component_list();
+ render_detail_panel();
}
function render_component_list() {
@@ -108,10 +166,9 @@ function render_component_list() {
const visible = all_components.filter(c => matches_search(c, query));
if (visible.length === 0) {
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.textContent = query ? 'No components match your search.' : 'No components yet. Add one!';
- list_el.replaceChildren(empty);
+ const el = clone('t-empty-block');
+ el.textContent = query ? 'No components match your search.' : 'No components yet. Add one!';
+ list_el.replaceChildren(el);
return;
}
@@ -121,49 +178,162 @@ function render_component_list() {
function build_component_row(comp) {
const row = clone('t-component-row');
set_text(row, '.component-name', comp.name);
- set_text(row, '.component-description', comp.description || '');
- // Field tags
- const fields_el = qs(row, '.component-fields');
+ const tags_el = qs(row, '.component-tags');
const field_entries = Object.entries(comp.fields ?? {});
if (field_entries.length > 0) {
- fields_el.replaceChildren(...field_entries.map(([fid, val]) => {
+ tags_el.replaceChildren(...field_entries.slice(0, 4).map(([fid, val]) => {
const tag = clone('t-field-tag');
const def = field_by_id(fid);
- const label = def ? def.name : fid;
- const display_val = def?.unit ? `${val} ${def.unit}` : String(val);
- set_text(tag, '.tag-name', label);
- set_text(tag, '.tag-value', display_val);
+ set_text(tag, '.tag-name', def ? def.name : fid);
+ set_text(tag, '.tag-value', def?.unit ? `${val} ${def.unit}` : String(val));
return tag;
}));
}
- // Location badges
- const locs_el = qs(row, '.component-locations');
- const entries = inventory_for_component(comp.id);
- if (entries.length > 0) {
- locs_el.replaceChildren(...entries.map(entry => {
- const badge = clone('t-location-badge');
- badge.classList.add(`type-${entry.location_type}`);
- set_text(badge, '.badge-icon', LOCATION_TYPE_ICON[entry.location_type] ?? '');
- const ref_text = entry.location_ref || LOCATION_TYPE_LABEL[entry.location_type];
- const qty_text = entry.quantity ? ` ×${entry.quantity}` : '';
- set_text(badge, '.badge-text', ref_text + qty_text);
- return badge;
- }));
+ if (comp.id === selected_component_id) {
+ row.classList.add('selected');
}
- qs(row, '.btn-edit').addEventListener('click', () => open_component_dialog(comp));
- qs(row, '.btn-delete').addEventListener('click', () => confirm_delete(
- `Delete component "${comp.name}"? Inventory entries for it will remain but become orphaned.`,
+ row.addEventListener('click', () => {
+ document.querySelectorAll('.component-row.selected').forEach(r => r.classList.remove('selected'));
+ row.classList.add('selected');
+ selected_component_id = comp.id;
+ render_detail_panel();
+ });
+
+ return row;
+}
+
+// ---------------------------------------------------------------------------
+// Render: Detail panel
+// ---------------------------------------------------------------------------
+
+function render_detail_panel() {
+ const pane = document.getElementById('detail-pane');
+ if (!pane) return;
+
+ const comp = selected_component_id ? component_by_id(selected_component_id) : null;
+ if (!comp) {
+ pane.replaceChildren(clone('t-detail-placeholder'));
+ return;
+ }
+
+ const content = clone('t-detail-content');
+
+ // Header
+ set_text(content, '.detail-name', comp.name);
+ set_text(content, '.detail-description', comp.description || '');
+ qs(content, '.detail-edit-btn').addEventListener('click', () => open_component_dialog(comp));
+ qs(content, '.detail-delete-btn').addEventListener('click', () => confirm_delete(
+ `Delete component "${comp.name}"? Inventory entries will become orphaned.`,
async () => {
await api.delete_component(comp.id);
all_components = all_components.filter(c => c.id !== comp.id);
- render_component_list();
+ selected_component_id = null;
+ render();
}
));
- return row;
+ // Fields
+ const fields_el = qs(content, '.detail-fields-list');
+ const field_entries = Object.entries(comp.fields ?? {});
+ if (field_entries.length === 0) {
+ fields_el.textContent = 'No fields set.';
+ fields_el.classList.add('detail-empty-note');
+ } else {
+ fields_el.replaceChildren(...field_entries.map(([fid, val]) => {
+ const row = clone('t-detail-field-row');
+ const def = field_by_id(fid);
+ set_text(row, '.detail-field-name', def ? def.name : fid);
+ set_text(row, '.detail-field-value', def?.unit ? `${val} ${def.unit}` : String(val));
+ return row;
+ }));
+ }
+
+ // Component images
+ build_image_grid(
+ qs(content, '.comp-image-grid'),
+ comp.images ?? [],
+ async (img_id) => {
+ const res = await fetch(`/api/components/${comp.id}/images/${img_id}`, { method: 'DELETE' });
+ const data = await res.json();
+ if (!data.ok) { alert(data.error); return; }
+ const idx = all_components.findIndex(c => c.id === comp.id);
+ if (idx !== -1) all_components[idx] = data.component;
+ render_detail_panel();
+ }
+ );
+ qs(content, '.comp-img-input').addEventListener('change', async (e) => {
+ await upload_images([...e.target.files], `/api/components/${comp.id}/images`, (data) => {
+ const idx = all_components.findIndex(c => c.id === comp.id);
+ if (idx !== -1) all_components[idx] = data.component;
+ render_detail_panel();
+ });
+ e.target.value = '';
+ });
+
+ // Inventory entries
+ const inv_list = qs(content, '.detail-inventory-list');
+ const entries = inventory_for_component(comp.id);
+ if (entries.length === 0) {
+ const note = document.createElement('p');
+ note.textContent = 'No inventory entries.';
+ note.className = 'detail-empty-note';
+ inv_list.replaceChildren(note);
+ } else {
+ inv_list.replaceChildren(...entries.map(e => build_detail_inv_entry(e)));
+ }
+
+ qs(content, '.detail-add-inv-btn').addEventListener('click', () => open_inventory_dialog(null, comp.id));
+
+ pane.replaceChildren(content);
+}
+
+function build_detail_inv_entry(entry) {
+ const el = clone('t-detail-inv-entry');
+
+ const type_el = qs(el, '.detail-inv-type');
+ type_el.className = `type-pill type-${entry.location_type}`;
+ type_el.textContent = LOCATION_TYPE_LABEL[entry.location_type] ?? entry.location_type;
+
+ set_text(el, '.detail-inv-ref', entry.location_ref || '—');
+ set_text(el, '.detail-inv-qty', entry.quantity ? `×${entry.quantity}` : '');
+ set_text(el, '.detail-inv-notes', entry.notes || '');
+
+ // Inventory entry images
+ build_image_grid(
+ qs(el, '.inv-image-grid'),
+ entry.images ?? [],
+ async (img_id) => {
+ const res = await fetch(`/api/inventory/${entry.id}/images/${img_id}`, { method: 'DELETE' });
+ const data = await res.json();
+ if (!data.ok) { alert(data.error); return; }
+ const idx = all_inventory.findIndex(e => e.id === entry.id);
+ if (idx !== -1) all_inventory[idx] = data.entry;
+ render_detail_panel();
+ }
+ );
+ qs(el, '.inv-img-input').addEventListener('change', async (e) => {
+ await upload_images([...e.target.files], `/api/inventory/${entry.id}/images`, (data) => {
+ const idx = all_inventory.findIndex(e => e.id === entry.id);
+ if (idx !== -1) all_inventory[idx] = data.entry;
+ render_detail_panel();
+ });
+ e.target.value = '';
+ });
+
+ qs(el, '.btn-edit').addEventListener('click', () => open_inventory_dialog(entry));
+ qs(el, '.btn-delete').addEventListener('click', () => confirm_delete(
+ `Delete this inventory entry?`,
+ async () => {
+ await api.delete_inventory(entry.id);
+ all_inventory = all_inventory.filter(e => e.id !== entry.id);
+ render_detail_panel();
+ }
+ ));
+
+ return el;
}
// ---------------------------------------------------------------------------
@@ -199,10 +369,10 @@ function render_inventory_list() {
const visible = all_inventory.filter(e => matches_inventory_search(e, inventory_search.trim(), inventory_type_filter));
if (visible.length === 0) {
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.textContent = 'No inventory entries match your filter.';
- list_el.replaceChildren(empty);
+ const row = clone('t-empty-row');
+ row.querySelector('td').colSpan = 6;
+ row.querySelector('td').textContent = 'No inventory entries match your filter.';
+ list_el.replaceChildren(row);
return;
}
@@ -256,10 +426,10 @@ function render_field_list() {
const list_el = document.getElementById('field-list');
if (all_fields.length === 0) {
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.textContent = 'No field definitions yet. Add some!';
- list_el.replaceChildren(empty);
+ const row = clone('t-empty-row');
+ row.querySelector('td').colSpan = 4;
+ row.querySelector('td').textContent = 'No field definitions yet. Add some!';
+ list_el.replaceChildren(row);
return;
}
@@ -285,6 +455,552 @@ function build_field_row(fdef) {
return row;
}
+// ---------------------------------------------------------------------------
+// Render: Grids section
+// ---------------------------------------------------------------------------
+
+function render_grids() {
+ const main = document.getElementById('main');
+ if (grid_view_state === 'panels') { render_panel_manager(); return; }
+ if (grid_view_state === 'setup') { render_grid_setup(); return; }
+ if (grid_view_state === 'viewer') { render_grid_viewer(); return; }
+
+ let section_el = document.getElementById('section-grids');
+ if (!section_el) {
+ const frag = document.getElementById('t-section-grids').content.cloneNode(true);
+ main.replaceChildren(frag);
+ section_el = document.getElementById('section-grids');
+
+ qs(section_el, '#btn-new-grid').addEventListener('click', open_new_grid_dialog);
+ qs(section_el, '#btn-tab-grids').addEventListener('click', () => {
+ history.replaceState(null, '', '/grids');
+ grid_tab = 'grids';
+ update_grid_tabs(section_el);
+ });
+ qs(section_el, '#btn-tab-sources').addEventListener('click', () => {
+ history.replaceState(null, '', '/grids/sources');
+ grid_tab = 'sources';
+ update_grid_tabs(section_el);
+ });
+ qs(section_el, '#source-upload-input').addEventListener('change', async (e) => {
+ const files = [...e.target.files];
+ if (!files.length) return;
+ const form = new FormData();
+ files.forEach(f => form.append('images', f));
+ const res = await fetch('/api/source-images', { method: 'POST', body: form });
+ const data = await res.json();
+ if (!data.ok) { alert(data.error); return; }
+ all_sources.unshift(...data.sources);
+ e.target.value = '';
+ render_source_list();
+ });
+ }
+
+ update_grid_tabs(section_el);
+ render_grid_list();
+ render_source_list();
+}
+
+function update_grid_tabs(section_el) {
+ qs(section_el, '#btn-tab-grids').classList.toggle('active', grid_tab === 'grids');
+ qs(section_el, '#btn-tab-sources').classList.toggle('active', grid_tab === 'sources');
+ qs(section_el, '#btn-new-grid').hidden = (grid_tab !== 'grids');
+ qs(section_el, '#btn-upload-sources').hidden = (grid_tab !== 'sources');
+ qs(section_el, '#tab-grids-content').hidden = (grid_tab !== 'grids');
+ qs(section_el, '#tab-sources-content').hidden = (grid_tab !== 'sources');
+}
+
+function render_grid_list() {
+ const list_el = document.getElementById('grid-list');
+ if (!list_el) return;
+ if (all_grids.length === 0 && all_drafts.length === 0) {
+ const el = clone('t-empty-block');
+ el.textContent = 'No grids yet. Click "+ New grid" to get started.';
+ list_el.replaceChildren(el);
+ return;
+ }
+ list_el.replaceChildren(
+ ...all_drafts.map(build_draft_card),
+ ...all_grids.map(build_grid_card),
+ );
+}
+
+function render_source_list() {
+ const list_el = document.getElementById('source-image-list');
+ if (!list_el) return;
+ if (all_sources.length === 0) {
+ const el = clone('t-empty-block');
+ el.textContent = 'No source images yet. Upload photos of your assortment boxes.';
+ list_el.replaceChildren(el);
+ return;
+ }
+ list_el.replaceChildren(...all_sources.map(src => build_source_card(src, false)));
+}
+
+function build_source_card(src, selectable, on_select = null) {
+ const card = clone('t-source-card');
+ const img_el = qs(card, '.source-card-img');
+ img_el.src = `/img/${src.id}`;
+ qs(card, '.source-card-link').href = `/img/${src.id}`;
+ set_text(card, '.source-card-meta', [src.original_name, `${src.width}×${src.height}`].filter(Boolean).join(' · '));
+
+ if (selectable) {
+ card.classList.add('selectable');
+ img_el.addEventListener('click', (e) => {
+ e.preventDefault();
+ if (on_select) on_select(src);
+ });
+ }
+
+ qs(card, '.source-card-delete').addEventListener('click', async () => {
+ try {
+ await api.delete_source_image(src.id);
+ all_sources = all_sources.filter(s => s.id !== src.id);
+ render_source_list();
+ refresh_picker_grid();
+ } catch (err) {
+ alert(err.message);
+ }
+ });
+
+ return card;
+}
+
+function build_grid_card(grid) {
+ const card = clone('t-grid-card');
+ set_text(card, '.grid-card-name', grid.name);
+ set_text(card, '.grid-card-meta', `${grid.rows} × ${grid.cols} • ${grid.cell_w}×${grid.cell_h}px cells`);
+
+ const preview = qs(card, '.grid-card-preview');
+ const sample = grid.cells.flat().filter(f => f).slice(0, 4);
+ preview.replaceChildren(...sample.map(filename => {
+ const img = document.createElement('img');
+ img.className = 'grid-card-preview-thumb';
+ img.src = `/img/${filename}`;
+ img.alt = '';
+ return img;
+ }));
+
+ card.addEventListener('click', (e) => {
+ if (e.target.closest('.btn-delete')) return;
+ navigate('/grids/viewer/' + grid.id);
+ });
+
+ qs(card, '.btn-delete').addEventListener('click', () => confirm_delete(
+ `Delete grid "${grid.name}" and all its cell images?`,
+ async () => {
+ await api.delete_grid(grid.id);
+ all_grids = all_grids.filter(g => g.id !== grid.id);
+ render_grid_list();
+ }
+ ));
+
+ return card;
+}
+
+function build_draft_card(draft) {
+ const card = clone('t-draft-card');
+ set_text(card, '.draft-card-name', draft.name);
+ const done = draft.panels.filter(p => p?.corners).length;
+ set_text(card, '.draft-card-meta',
+ `${draft.rows}×${draft.cols} cells · ${done}/${draft.panels.length} panels configured`
+ );
+
+ card.addEventListener('click', (e) => {
+ if (e.target.closest('.btn-delete')) return;
+ navigate('/grids/draft/' + draft.id);
+ });
+
+ qs(card, '.btn-delete').addEventListener('click', () => confirm_delete(
+ `Discard draft "${draft.name}"?`,
+ async () => {
+ await api.delete_grid_draft(draft.id);
+ all_drafts = all_drafts.filter(d => d.id !== draft.id);
+ render_grid_list();
+ }
+ ));
+
+ return card;
+}
+
+// --- New grid dialog ---
+
+let new_grid_dialog = null;
+
+function open_new_grid_dialog() {
+ if (!new_grid_dialog) {
+ const frag = document.getElementById('t-dialog-new-grid').content.cloneNode(true);
+ document.body.appendChild(frag);
+ new_grid_dialog = document.getElementById('dialog-new-grid');
+
+ const summary_el = document.getElementById('ng-summary');
+ function update_summary() {
+ const rows = parseInt(document.getElementById('ng-rows').value) || 1;
+ const cols = parseInt(document.getElementById('ng-cols').value) || 1;
+ const photo_r = parseInt(document.getElementById('ng-photo-rows').value) || rows;
+ const photo_c = parseInt(document.getElementById('ng-photo-cols').value) || cols;
+ const panel_rows = Math.ceil(rows / photo_r);
+ const panel_cols = Math.ceil(cols / photo_c);
+ const total = panel_rows * panel_cols;
+ summary_el.textContent = total === 1
+ ? `1 photo covering all ${rows}×${cols} cells`
+ : `${total} photos (${panel_rows}×${panel_cols} grid)`;
+ }
+ ['ng-rows','ng-cols','ng-photo-rows','ng-photo-cols'].forEach(id =>
+ document.getElementById(id).addEventListener('input', update_summary)
+ );
+
+ document.getElementById('ng-cancel').addEventListener('click', () => new_grid_dialog.close());
+ document.getElementById('form-new-grid').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const name = document.getElementById('ng-name').value.trim() || 'Grid';
+ const rows = Math.max(1, parseInt(document.getElementById('ng-rows').value) || 4);
+ const cols = Math.max(1, parseInt(document.getElementById('ng-cols').value) || 6);
+ const photo_r = Math.max(1, parseInt(document.getElementById('ng-photo-rows').value) || rows);
+ const photo_c = Math.max(1, parseInt(document.getElementById('ng-photo-cols').value) || cols);
+ const panel_rows = Math.ceil(rows / photo_r);
+ const panel_cols = Math.ceil(cols / photo_c);
+ new_grid_dialog.close();
+ const body = {
+ name, rows, cols, panel_rows, panel_cols,
+ panels: Array.from({ length: panel_rows * panel_cols }, () => ({ source_id: null, corners: null })),
+ };
+ try {
+ const result = await api.create_grid_draft(body);
+ all_drafts.unshift(result.draft);
+ navigate('/grids/draft/' + result.draft.id);
+ } catch (err) {
+ alert(`Error creating draft: ${err.message}`);
+ }
+ });
+ update_summary();
+ }
+ // Reset form
+ document.getElementById('ng-name').value = '';
+ document.getElementById('ng-rows').value = '4';
+ document.getElementById('ng-cols').value = '6';
+ document.getElementById('ng-photo-rows').value = '4';
+ document.getElementById('ng-photo-cols').value = '6';
+ document.getElementById('ng-summary').textContent = '1 photo covering all 4×6 cells';
+ new_grid_dialog.showModal();
+ document.getElementById('ng-name').focus();
+}
+
+// --- Panel manager ---
+
+function render_panel_manager() {
+ const main = document.getElementById('main');
+ const frag = document.getElementById('t-panel-manager').content.cloneNode(true);
+ main.replaceChildren(frag);
+
+ const d = grid_draft;
+ set_text(document.getElementById('panel-manager'), '.pm-name', d.name);
+ const configured_count = d.panels.filter(p => p?.corners).length;
+ set_text(document.getElementById('panel-manager'), '.pm-meta',
+ `${d.rows}×${d.cols} cells · ${d.panel_rows}×${d.panel_cols} panels · ${configured_count}/${d.panels.length} configured`
+ );
+
+ const process_btn = document.getElementById('pm-process');
+ process_btn.disabled = configured_count === 0;
+ process_btn.textContent = d.edit_grid_id ? 'Done' : 'Process configured panels';
+
+ document.getElementById('pm-cancel').addEventListener('click', async () => {
+ if (!d.edit_grid_id) await save_draft();
+ navigate(d.edit_grid_id ? `/grids/viewer/${d.edit_grid_id}` : '/grids');
+ });
+
+ process_btn.addEventListener('click', d.edit_grid_id
+ ? () => navigate(`/grids/viewer/${d.edit_grid_id}`)
+ : process_grid_draft
+ );
+
+ // Build panel slot grid
+ const slot_grid = document.getElementById('panel-slot-grid');
+ slot_grid.style.gridTemplateColumns = `repeat(${d.panel_cols}, 100px)`;
+
+ d.panels.forEach((panel, idx) => {
+ const pr = Math.floor(idx / d.panel_cols);
+ const pc = idx % d.panel_cols;
+ const base_rows = Math.floor(d.rows / d.panel_rows);
+ const base_cols = Math.floor(d.cols / d.panel_cols);
+ const p_rows = pr === d.panel_rows - 1 ? d.rows - pr * base_rows : base_rows;
+ const p_cols = pc === d.panel_cols - 1 ? d.cols - pc * base_cols : base_cols;
+ const row_start = pr * base_rows + 1;
+ const col_start = pc * base_cols + 1;
+
+ const slot = clone('t-panel-slot');
+ set_text(slot, '.panel-slot-label', `Panel ${pr + 1},${pc + 1}`);
+ set_text(slot, '.panel-slot-range',
+ `rows ${row_start}–${row_start + p_rows - 1} · cols ${col_start}–${col_start + p_cols - 1}`
+ );
+
+ if (panel?.source_id) {
+ slot.classList.add('configured');
+ const thumb = qs(slot, '.panel-slot-thumb');
+ thumb.src = `/img/${panel.source_id}`;
+ thumb.hidden = false;
+ qs(slot, '.panel-slot-empty-icon').hidden = true;
+ }
+
+ slot.addEventListener('click', () => open_panel_source_picker(idx));
+ slot_grid.appendChild(slot);
+ });
+}
+
+async function save_draft() {
+ const d = grid_draft;
+ if (!d) return;
+ const body = {
+ name: d.name, rows: d.rows, cols: d.cols,
+ panel_rows: d.panel_rows, panel_cols: d.panel_cols,
+ panels: d.panels,
+ };
+ try {
+ if (d.id) {
+ const result = await api.update_grid_draft(d.id, body);
+ grid_draft = result.draft;
+ const idx = all_drafts.findIndex(x => x.id === d.id);
+ if (idx !== -1) all_drafts[idx] = result.draft;
+ } else {
+ const result = await api.create_grid_draft(body);
+ grid_draft = result.draft;
+ all_drafts.unshift(result.draft);
+ }
+ } catch (err) {
+ console.error('Draft save failed:', err);
+ }
+}
+
+async function process_grid_draft() {
+ const progress_el = document.getElementById('pm-progress');
+ const btn = document.getElementById('pm-process');
+ progress_el.hidden = false;
+ progress_el.textContent = 'Processing panels…';
+ btn.disabled = true;
+
+ try {
+ const d = grid_draft;
+ const res = await fetch('/api/grid-images', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: d.name, rows: d.rows, cols: d.cols,
+ panel_rows: d.panel_rows, panel_cols: d.panel_cols,
+ panels: d.panels.map(p => ({ source_id: p.source_id, corners: p.corners })),
+ }),
+ });
+ const data = await res.json();
+ if (!data.ok) { alert(data.error); return; }
+ // Delete draft now that it's been processed
+ if (d.id) {
+ await api.delete_grid_draft(d.id).catch(() => {});
+ all_drafts = all_drafts.filter(x => x.id !== d.id);
+ }
+ all_grids.unshift(data.grid);
+ navigate('/grids/viewer/' + data.grid.id);
+ } catch (err) {
+ alert(`Error: ${err.message}`);
+ } finally {
+ if (document.getElementById('pm-progress')) {
+ document.getElementById('pm-progress').hidden = true;
+ if (document.getElementById('pm-process')) document.getElementById('pm-process').disabled = false;
+ }
+ }
+}
+
+// --- Source picker dialog (for panel configuration) ---
+
+let picker_dialog = null;
+
+function refresh_picker_grid(on_select) {
+ const grid_el = document.getElementById('source-picker-grid');
+ if (!grid_el) return;
+ if (all_sources.length === 0) {
+ const el = clone('t-empty-block');
+ el.textContent = 'No source images yet. Upload one above.';
+ grid_el.replaceChildren(el);
+ return;
+ }
+ grid_el.replaceChildren(...all_sources.map(src => build_source_card(src, true, (selected) => {
+ picker_dialog.close();
+ on_select(selected.id);
+ })));
+}
+
+function open_panel_source_picker(panel_idx) {
+ current_panel_idx = panel_idx;
+
+ const on_select = (source_id) => {
+ const d = grid_draft;
+ const base = d.edit_grid_id
+ ? `/grids/viewer/${d.edit_grid_id}`
+ : `/grids/draft/${d.id}`;
+ // Push URL but set state directly — parse_url can't restore grid_source_id
+ // for panels that haven't been processed yet (no saved source_id to read back)
+ history.pushState(null, '', `${base}/panel/${current_panel_idx}`);
+ grid_source_id = source_id;
+ grid_view_state = 'setup';
+ render();
+ };
+
+ if (!picker_dialog) {
+ const frag = document.getElementById('t-dialog-source-picker').content.cloneNode(true);
+ document.body.appendChild(frag);
+ picker_dialog = document.getElementById('dialog-source-picker');
+
+ qs(picker_dialog, '#picker-cancel').addEventListener('click', () => picker_dialog.close());
+ qs(picker_dialog, '#picker-upload-input').addEventListener('change', async (e) => {
+ const files = [...e.target.files];
+ if (!files.length) return;
+ const form = new FormData();
+ files.forEach(f => form.append('images', f));
+ const res = await fetch('/api/source-images', { method: 'POST', body: form });
+ const data = await res.json();
+ if (!data.ok) { alert(data.error); return; }
+ all_sources.unshift(...data.sources);
+ e.target.value = '';
+ refresh_picker_grid(on_select);
+ render_source_list();
+ });
+ }
+
+ // Rebind on_select each time (panel_idx changes)
+ picker_dialog._on_select = on_select;
+ refresh_picker_grid(on_select);
+ picker_dialog.showModal();
+}
+
+function render_grid_setup() {
+ const main = document.getElementById('main');
+ const frag = document.getElementById('t-grid-setup').content.cloneNode(true);
+ main.replaceChildren(frag);
+
+ const d = grid_draft;
+ const pi = current_panel_idx;
+ const pr = Math.floor(pi / d.panel_cols);
+ const pc = pi % d.panel_cols;
+ const base_rows = Math.floor(d.rows / d.panel_rows);
+ const base_cols = Math.floor(d.cols / d.panel_cols);
+ const p_rows = pr === d.panel_rows - 1 ? d.rows - pr * base_rows : base_rows;
+ const p_cols = pc === d.panel_cols - 1 ? d.cols - pc * base_cols : base_cols;
+ const row_start = pr * base_rows + 1;
+ const col_start = pc * base_cols + 1;
+
+ const total = d.panel_rows * d.panel_cols;
+ document.getElementById('gs-panel-info').innerHTML =
+ `${d.name} — Panel ${pi + 1} of ${total}
` +
+ `Covers rows ${row_start}–${row_start + p_rows - 1}, cols ${col_start}–${col_start + p_cols - 1}`;
+
+ const canvas_el = document.getElementById('grid-canvas');
+ const cell_size_el = document.getElementById('gs-cell-size');
+
+ grid_setup_instance = new Grid_Setup(canvas_el);
+ grid_setup_instance.set_rows(p_rows);
+ grid_setup_instance.set_cols(p_cols);
+
+ // Restore previously saved corners for this panel if any
+ const saved_corners = d.panels[pi]?.corners;
+
+ grid_setup_instance.load_image(`/img/${grid_source_id}`).then(({ width, height }) => {
+ if (saved_corners) grid_setup_instance.set_corners(saved_corners);
+ update_cell_size_hint();
+ });
+
+ function update_cell_size_hint() {
+ const corners = grid_setup_instance.get_corners();
+ if (!corners) return;
+ const top_w = Math.hypot(corners[1].x - corners[0].x, corners[1].y - corners[0].y);
+ const left_h = Math.hypot(corners[3].x - corners[0].x, corners[3].y - corners[0].y);
+ const cw = Math.round(Math.min(480, Math.max(48, top_w / p_cols)));
+ const ch = Math.round(Math.min(480, Math.max(48, left_h / p_rows)));
+ cell_size_el.textContent = `Estimated cell size: ${cw} × ${ch} px`;
+ }
+
+ document.getElementById('gs-cancel').addEventListener('click', () => {
+ navigate(panels_url());
+ });
+
+ document.getElementById('gs-confirm').addEventListener('click', async () => {
+ const corners = grid_setup_instance.get_corners();
+ if (!corners) return;
+ const btn = document.getElementById('gs-confirm');
+ btn.disabled = true;
+
+ if (d.edit_grid_id) {
+ // Edit mode: patch the live grid immediately
+ const progress_el = document.getElementById('gs-progress');
+ progress_el.hidden = false;
+ progress_el.textContent = 'Processing panel…';
+ try {
+ const result = await api.update_grid_panel(d.edit_grid_id, pi, {
+ source_id: grid_source_id, corners,
+ });
+ const idx = all_grids.findIndex(g => g.id === d.edit_grid_id);
+ if (idx !== -1) all_grids[idx] = result.grid;
+ d.panels[pi] = { source_id: grid_source_id, corners };
+ } catch (err) {
+ alert(`Error: ${err.message}`);
+ btn.disabled = false;
+ progress_el.hidden = true;
+ return;
+ }
+ } else {
+ d.panels[pi] = { source_id: grid_source_id, corners };
+ await save_draft();
+ }
+
+ navigate(panels_url());
+ });
+}
+
+function render_grid_viewer() {
+ const grid = all_grids.find(g => g.id === current_grid_id);
+ if (!grid) { navigate('/grids'); return; }
+
+ const main = document.getElementById('main');
+ const frag = document.getElementById('t-grid-viewer').content.cloneNode(true);
+ main.replaceChildren(frag);
+
+ set_text(document.getElementById('grid-viewer'), '.viewer-name', grid.name);
+ set_text(document.getElementById('grid-viewer'), '.viewer-meta',
+ `${grid.rows} rows × ${grid.cols} columns • ${grid.cell_w}×${grid.cell_h}px per cell`);
+
+ document.getElementById('gv-back').addEventListener('click', () => navigate('/grids'));
+
+ document.getElementById('gv-edit-panels').addEventListener('click', () => {
+ navigate(`/grids/viewer/${grid.id}/panels`);
+ });
+
+ document.getElementById('gv-delete').addEventListener('click', () => confirm_delete(
+ `Delete grid "${grid.name}" and all its cell images?`,
+ async () => {
+ await api.delete_grid(grid.id);
+ all_grids = all_grids.filter(g => g.id !== grid.id);
+ navigate('/grids');
+ }
+ ));
+
+ // Build cell grid
+ const cells_el = document.getElementById('grid-cells');
+ cells_el.style.gridTemplateColumns = `repeat(${grid.cols}, 1fr)`;
+
+ const all_cells = grid.cells.flat().map((filename, idx) => {
+ const row = Math.floor(idx / grid.cols);
+ const col = idx % grid.cols;
+ const cell = clone('t-grid-cell');
+ if (filename) {
+ const img = qs(cell, '.grid-cell-img');
+ img.src = `/img/${filename}`;
+ img.addEventListener('click', () => window.open(`/img/${filename}`, '_blank'));
+ } else {
+ cell.classList.add('empty');
+ }
+ set_text(cell, '.grid-cell-label', `${row},${col}`);
+ return cell;
+ });
+ cells_el.replaceChildren(...all_cells);
+}
+
// ---------------------------------------------------------------------------
// Dialog: Component
// ---------------------------------------------------------------------------
@@ -299,29 +1015,26 @@ function open_component_dialog(comp = null) {
const field_rows_el = qs(dlg, '#c-field-rows');
const add_field_sel = qs(dlg, '#c-add-field-select');
- title.textContent = comp ? `Edit component` : 'Add component';
+ title.textContent = comp ? 'Edit component' : 'Add component';
name_input.value = comp?.name ?? '';
desc_input.value = comp?.description ?? '';
- // Build field rows from component's existing values
const active_fields = new Map(Object.entries(comp?.fields ?? {}));
function rebuild_field_rows() {
field_rows_el.replaceChildren(...[...active_fields.entries()].map(([fid, val]) => {
const def = field_by_id(fid);
- const label_text = def ? def.name : fid;
- const unit_text = def?.unit ? ` [${def.unit}]` : '';
const row_el = document.createElement('div');
row_el.className = 'c-field-input-row';
const label_el = document.createElement('div');
label_el.className = 'c-field-input-label';
- label_el.textContent = label_text;
- if (unit_text) {
+ label_el.textContent = def ? def.name : fid;
+ if (def?.unit) {
const unit_span = document.createElement('span');
unit_span.className = 'c-field-unit-hint';
- unit_span.textContent = unit_text;
+ unit_span.textContent = ` [${def.unit}]`;
label_el.appendChild(unit_span);
}
@@ -331,9 +1044,7 @@ function open_component_dialog(comp = null) {
input_el.value = val;
input_el.autocomplete = 'off';
input_el.dataset.field_id = fid;
- input_el.addEventListener('input', (e) => {
- active_fields.set(fid, e.target.value);
- });
+ input_el.addEventListener('input', (e) => active_fields.set(fid, e.target.value));
const remove_btn = document.createElement('button');
remove_btn.type = 'button';
@@ -367,7 +1078,6 @@ function open_component_dialog(comp = null) {
rebuild_field_rows();
rebuild_add_select();
- // Wire add-field select
const old_handler = add_field_sel._change_handler;
if (old_handler) add_field_sel.removeEventListener('change', old_handler);
add_field_sel._change_handler = (e) => {
@@ -376,7 +1086,6 @@ function open_component_dialog(comp = null) {
active_fields.set(fid, '');
rebuild_field_rows();
rebuild_add_select();
- // Focus the new input
const inputs = field_rows_el.querySelectorAll(`[data-field_id="${fid}"]`);
if (inputs.length) inputs[inputs.length - 1].focus();
};
@@ -398,6 +1107,7 @@ function open_component_dialog(comp = null) {
const result = await api.create_component(body);
all_components.push(result.component);
all_components.sort((a, b) => a.name.localeCompare(b.name));
+ selected_component_id = result.component.id;
}
};
@@ -411,7 +1121,7 @@ function open_component_dialog(comp = null) {
let inventory_dialog_callback = null;
-function open_inventory_dialog(entry = null) {
+function open_inventory_dialog(entry = null, default_component_id = null) {
const dlg = document.getElementById('dialog-inventory');
const title = qs(dlg, '.dialog-title');
const comp_sel = qs(dlg, '#i-component');
@@ -423,7 +1133,6 @@ function open_inventory_dialog(entry = null) {
title.textContent = entry ? 'Edit inventory entry' : 'Add inventory entry';
- // Populate component dropdown
comp_sel.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— select component —' }),
...all_components.map(c => Object.assign(document.createElement('option'), {
@@ -431,7 +1140,7 @@ function open_inventory_dialog(entry = null) {
textContent: c.name,
}))
);
- comp_sel.value = entry?.component_id ?? '';
+ comp_sel.value = entry?.component_id ?? default_component_id ?? '';
type_sel.value = entry?.location_type ?? 'physical';
ref_input.value = entry?.location_ref ?? '';
qty_input.value = entry?.quantity ?? '';
@@ -522,14 +1231,109 @@ function confirm_delete(message, on_confirm) {
dlg.showModal();
}
+// ---------------------------------------------------------------------------
+// Routing
+// ---------------------------------------------------------------------------
+
+function parse_url() {
+ const parts = window.location.pathname.split('/').filter(Boolean);
+
+ // Reset state
+ section = 'components';
+ grid_view_state = 'list';
+ grid_tab = 'grids';
+ current_grid_id = null;
+ current_panel_idx = null;
+ grid_draft = null;
+ grid_source_id = null;
+
+ const [p0, p1, p2, p3, p4] = parts;
+
+ if (!p0 || p0 === 'components') {
+ section = 'components';
+ } else if (p0 === 'inventory') {
+ section = 'inventory';
+ } else if (p0 === 'fields') {
+ section = 'fields';
+ } else if (p0 === 'grids') {
+ section = 'grids';
+ if (p1 === 'sources') {
+ grid_tab = 'sources';
+ } else if (p1 === 'viewer' && p2) {
+ const grid = all_grids.find(g => g.id === p2);
+ if (!grid) { history.replaceState(null, '', '/grids'); return; }
+ current_grid_id = p2;
+ if (p3 === 'panels') {
+ grid_draft = build_edit_draft(grid);
+ grid_view_state = 'panels';
+ } else if (p3 === 'panel' && p4 !== undefined) {
+ grid_draft = build_edit_draft(grid);
+ const pi = parseInt(p4);
+ const src = grid_draft.panels[pi]?.source_id;
+ if (!src) { history.replaceState(null, '', `/grids/viewer/${p2}/panels`); grid_view_state = 'panels'; return; }
+ current_panel_idx = pi;
+ grid_source_id = src;
+ grid_view_state = 'setup';
+ } else {
+ grid_view_state = 'viewer';
+ }
+ } else if (p1 === 'draft' && p2) {
+ const draft = all_drafts.find(d => d.id === p2);
+ if (!draft) { history.replaceState(null, '', '/grids'); return; }
+ grid_draft = { ...draft };
+ if (p3 === 'panel' && p4 !== undefined) {
+ const pi = parseInt(p4);
+ const src = grid_draft.panels[pi]?.source_id;
+ if (!src) { history.replaceState(null, '', `/grids/draft/${p2}`); grid_view_state = 'panels'; return; }
+ current_panel_idx = pi;
+ grid_source_id = src;
+ grid_view_state = 'setup';
+ } else {
+ grid_view_state = 'panels';
+ }
+ }
+ }
+}
+
+function build_edit_draft(grid) {
+ return {
+ edit_grid_id: grid.id,
+ name: grid.name,
+ rows: grid.rows,
+ cols: grid.cols,
+ panel_rows: grid.panel_rows ?? 1,
+ panel_cols: grid.panel_cols ?? 1,
+ panels: grid.panels ?? [{ source_id: grid.source_id, corners: grid.corners }],
+ };
+}
+
+function navigate(path) {
+ history.pushState(null, '', path);
+ parse_url();
+ render();
+}
+
+function panels_url() {
+ const d = grid_draft;
+ return d?.edit_grid_id ? `/grids/viewer/${d.edit_grid_id}/panels` : `/grids/draft/${d.id}`;
+}
+
+function sync_nav() {
+ document.querySelectorAll('.nav-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.section === section);
+ });
+}
+
// ---------------------------------------------------------------------------
// Render dispatcher
// ---------------------------------------------------------------------------
function render() {
+ sync_nav();
if (section === 'components') render_components();
else if (section === 'inventory') render_inventory();
else if (section === 'fields') render_fields();
+ else if (section === 'grids') render_grids();
}
// ---------------------------------------------------------------------------
@@ -537,26 +1341,20 @@ function render() {
// ---------------------------------------------------------------------------
async function init() {
- // Load templates
const html = await fetch('/templates.html').then(r => r.text());
document.body.insertAdjacentHTML('beforeend', html);
- // Clone and mount dialogs
for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm']) {
- const frag = document.getElementById(id).content.cloneNode(true);
- document.body.appendChild(frag);
+ document.body.appendChild(document.getElementById(id).content.cloneNode(true));
}
- // Wire dialog form submissions
document.getElementById('form-component').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await component_dialog_callback?.();
document.getElementById('dialog-component').close();
render();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
+ } catch (err) { alert(`Error: ${err.message}`); }
});
document.getElementById('c-cancel').addEventListener('click', () => {
document.getElementById('dialog-component').close();
@@ -568,9 +1366,7 @@ async function init() {
await inventory_dialog_callback?.();
document.getElementById('dialog-inventory').close();
render();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
+ } catch (err) { alert(`Error: ${err.message}`); }
});
document.getElementById('i-cancel').addEventListener('click', () => {
document.getElementById('dialog-inventory').close();
@@ -582,9 +1378,7 @@ async function init() {
await field_dialog_callback?.();
document.getElementById('dialog-field').close();
render();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
+ } catch (err) { alert(`Error: ${err.message}`); }
});
document.getElementById('f-cancel').addEventListener('click', () => {
document.getElementById('dialog-field').close();
@@ -595,26 +1389,20 @@ async function init() {
await confirm_callback?.();
document.getElementById('dialog-confirm').close();
render();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
+ } catch (err) { alert(`Error: ${err.message}`); }
});
document.getElementById('confirm-cancel').addEventListener('click', () => {
document.getElementById('dialog-confirm').close();
});
- // Nav wiring
document.querySelectorAll('.nav-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- section = btn.dataset.section;
- render();
- });
+ btn.addEventListener('click', () => navigate('/' + btn.dataset.section));
});
- // Load data
+ window.addEventListener('popstate', () => { parse_url(); render(); });
+
await load_all();
+ parse_url();
render();
}
diff --git a/public/index.html b/public/index.html
index 969c583..855a333 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,7 +4,7 @@