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 @@ Electronics Inventory - + @@ -14,9 +14,10 @@ +
- + diff --git a/public/lib/api.mjs b/public/lib/api.mjs index 44b7d7f..9c3d711 100644 --- a/public/lib/api.mjs +++ b/public/lib/api.mjs @@ -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); diff --git a/public/style.css b/public/style.css index 2b8cc99..dcedc78 100644 --- a/public/style.css +++ b/public/style.css @@ -41,6 +41,7 @@ body { min-height: 100vh; display: flex; flex-direction: column; + overflow-x: hidden; } /* ===== HEADER ===== */ @@ -97,9 +98,276 @@ nav { #main { flex: 1; padding: 1.5rem; - max-width: 1200px; width: 100%; - margin: 0 auto; +} + +/* ===== SPLIT LAYOUT ===== */ + +.split-layout { + display: flex; + gap: 1.25rem; + align-items: flex-start; +} + +.list-pane { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + position: sticky; + top: calc(3rem + 1.5rem); /* header + main padding */ + max-height: calc(100vh - 3rem - 3rem); + overflow-y: auto; +} + +.quick-add-input { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + color: var(--text); + padding: 0.45rem 0.65rem; + border-radius: 4px; + font-size: 0.9rem; + font-family: inherit; + width: 100%; + transition: border-color 0.1s; +} + +.quick-add-input:focus { + outline: none; + border-color: var(--accent); +} + +.quick-add-input::placeholder { + color: var(--text-faint); +} + +.component-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.detail-pane { + flex: 1; + min-width: 0; + min-height: 300px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; +} + +/* ===== DETAIL PANEL ===== */ + +.detail-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 300px; + color: var(--text-faint); + font-size: 0.9rem; +} + +.detail-content { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.detail-header h2 { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 0.2rem; +} + +.detail-header p { + color: var(--text-dim); + font-size: 0.9rem; +} + +.detail-header-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.detail-block { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.detail-block-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-faint); + border-bottom: 1px solid var(--border); + padding-bottom: 0.35rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.detail-field-row { + display: grid; + grid-template-columns: 10rem 1fr; + gap: 0.5rem; + font-size: 0.9rem; + padding: 0.2rem 0; +} + +.detail-field-name { + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.detail-field-value { + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.detail-empty-note { + color: var(--text-faint); + font-size: 0.85rem; +} + +/* ===== IMAGE GRID ===== */ + +.detail-images-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem; +} + +.image-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.image-thumb { + position: relative; + width: 80px; + height: 80px; + flex-shrink: 0; +} + +.thumb-img { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--border); + display: block; + cursor: pointer; + transition: border-color 0.1s; +} + +.thumb-img:hover { + border-color: var(--accent); +} + +.thumb-delete { + position: absolute; + top: -6px; + right: -6px; + width: 1.4rem; + height: 1.4rem; + font-size: 0.7rem; + border-radius: 50%; + background: var(--surface); + border: 1px solid var(--border); + opacity: 0; + transition: opacity 0.1s; +} + +.image-thumb:hover .thumb-delete { + opacity: 1; +} + +.btn-add-image { + display: inline-flex; + align-items: center; + padding: 0 0.75rem; + height: 80px; + background: var(--surface-raised); + border: 1px dashed var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 0.85rem; + cursor: pointer; + transition: color 0.1s, border-color 0.1s; + white-space: nowrap; +} + +.btn-add-image:hover { + color: var(--text); + border-color: var(--accent); +} + +.btn-add-image-sm { + height: 40px; + font-size: 0.8rem; +} + +/* ===== DETAIL INVENTORY ENTRIES ===== */ + +.detail-inventory-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.detail-inv-entry { + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.6rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.detail-inv-header { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.detail-inv-ref { + font-weight: 500; + flex: 1; + min-width: 0; +} + +.detail-inv-qty { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--text-dim); +} + +.detail-inv-notes { + font-size: 0.85rem; + color: var(--text-dim); +} + +.detail-inv-images { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.4rem; } /* ===== SECTION TOOLBAR ===== */ @@ -156,31 +424,41 @@ nav { border-color: var(--border-focus); } -/* ===== TABLE HEADERS ===== */ +/* ===== DATA TABLES ===== */ -.table-header { - display: grid; +.data-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.25rem; +} + +.data-table th { padding: 0.4rem 0.75rem; + text-align: left; font-size: 0.75rem; + font-weight: normal; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-faint); border-bottom: 1px solid var(--border); - margin-bottom: 0.25rem; + white-space: nowrap; } -.inventory-grid { - grid-template-columns: 1fr 7rem 1fr 5rem 1fr 4.5rem; - gap: 0.5rem; - align-items: center; +.data-table td { + padding: 0.5rem 0.75rem; + vertical-align: middle; + border-bottom: 1px solid var(--border); } -.fields-grid { - grid-template-columns: 1fr 6rem 2fr 4.5rem; - gap: 0.5rem; - align-items: center; +.data-table .data-row:hover td { + background: var(--surface-raised); } +.data-table .col-type { width: 7rem; } +.data-table .col-qty { width: 5rem; } +.data-table .col-unit { width: 6rem; } +.data-table .col-actions { width: 5rem; } + /* ===== ITEM LIST ===== */ .item-list { @@ -196,62 +474,38 @@ nav { font-size: 0.9rem; } -/* ===== COMPONENT ROWS ===== */ +/* ===== COMPONENT ROWS (list pane) ===== */ .component-row { - display: flex; - align-items: flex-start; - gap: 0.75rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.75rem 1rem; - transition: border-color 0.1s; + padding: 0.5rem 0.65rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.1s; + border: 1px solid transparent; } .component-row:hover { - border-color: #3d3d3d; + background: var(--surface-raised); } -.component-main { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.component-header { - display: flex; - align-items: baseline; - gap: 0.75rem; - flex-wrap: wrap; +.component-row.selected { + background: rgba(91, 156, 246, 0.12); + border-color: var(--accent); } .component-name { - font-weight: 600; - font-size: 1rem; -} - -.component-description { - color: var(--text-dim); - font-size: 0.85rem; + font-size: 0.9rem; + font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 40ch; } -.component-fields { +.component-tags { display: flex; flex-wrap: wrap; - gap: 0.35rem; -} - -.component-locations { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; + gap: 0.25rem; + margin-top: 0.2rem; } /* ===== FIELD TAGS ===== */ @@ -279,50 +533,7 @@ nav { font-family: var(--font-mono); } -/* ===== LOCATION BADGES ===== */ -.location-badge { - display: inline-flex; - align-items: center; - gap: 0.3rem; - border-radius: 3px; - font-size: 0.8rem; - padding: 0.15rem 0.5rem; - font-weight: 500; -} - -.location-badge.type-physical { - background: var(--badge-physical-bg); - color: var(--badge-physical-text); -} - -.location-badge.type-bom { - background: var(--badge-bom-bg); - color: var(--badge-bom-text); -} - -.location-badge.type-digital { - background: var(--badge-digital-bg); - color: var(--badge-digital-text); -} - -/* ===== INVENTORY ROWS ===== */ - -.inventory-row { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.5rem 0.75rem; - transition: border-color 0.1s; -} - -.inventory-row:hover { - border-color: #3d3d3d; -} - -.inv-type-badge { - display: inline-flex; -} .type-pill { font-size: 0.75rem; @@ -350,20 +561,6 @@ nav { text-overflow: ellipsis; } -/* ===== FIELD DEF ROWS ===== */ - -.field-def-row { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.5rem 0.75rem; - transition: border-color 0.1s; -} - -.field-def-row:hover { - border-color: #3d3d3d; -} - .fdef-name { font-family: var(--font-mono); font-size: 0.9rem; @@ -456,6 +653,11 @@ nav { background: var(--danger-hover); } +.btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + /* ===== DIALOGS ===== */ .app-dialog { @@ -466,6 +668,8 @@ nav { padding: 1.5rem; width: min(500px, 95vw); font-family: inherit; + /* global * reset zeroes margin, which pins dialogs to top-left */ + margin: auto; } .app-dialog-sm { @@ -590,3 +794,520 @@ nav { font-size: 0.95rem; line-height: 1.5; } + +/* ===== TAB BAR ===== */ + +.tab-bar { + display: flex; + gap: 0.25rem; + flex: 1; +} + +.tab-btn { + background: none; + border: 1px solid transparent; + border-bottom: 2px solid transparent; + color: var(--text-dim); + padding: 0.35rem 0.9rem; + cursor: pointer; + font-size: 0.9rem; + font-family: inherit; + border-radius: 4px 4px 0 0; + transition: color 0.1s, border-color 0.1s; +} + +.tab-btn:hover { + color: var(--text); +} + +.tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: rgba(91, 156, 246, 0.07); +} + +/* ===== SOURCE IMAGE GALLERY ===== */ + +.source-gallery { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding-top: 0.5rem; +} + +.source-card { + position: relative; + width: 140px; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.source-card-link { + display: block; +} + +.source-card-img { + width: 140px; + height: 100px; + object-fit: cover; + border-radius: 5px; + border: 2px solid var(--border); + display: block; + cursor: pointer; + transition: border-color 0.1s; +} + +.source-card-img:hover { + border-color: var(--accent); +} + +.source-card.selectable .source-card-img { + cursor: pointer; +} + +.source-card.selectable:hover .source-card-img { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(91, 156, 246, 0.3); +} + +.source-card-meta { + font-size: 0.75rem; + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-card-delete { + position: absolute; + top: -6px; + right: -6px; + width: 1.4rem; + height: 1.4rem; + font-size: 0.7rem; + border-radius: 50%; + background: var(--surface); + border: 1px solid var(--border); + opacity: 0; + transition: opacity 0.1s; +} + +.source-card:hover .source-card-delete { + opacity: 1; +} + +/* ===== PICKER DIALOG ===== */ + +.app-dialog-wide { + width: min(820px, 95vw); +} + +.picker-toolbar { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.picker-hint { + font-size: 0.85rem; + color: var(--text-faint); +} + +.picker-gallery { + max-height: 60vh; + overflow-y: auto; + padding: 0.25rem; + border: 1px solid var(--border); + border-radius: 4px; + min-height: 120px; +} + +/* ===== GRIDS SECTION ===== */ + +.draft-card { + display: flex; + align-items: center; + gap: 1rem; + background: var(--surface); + border: 1px dashed var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + cursor: pointer; + transition: border-color 0.1s; +} + +.draft-card:hover { + border-color: var(--accent); +} + +.draft-badge { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--warning); + border: 1px solid var(--warning); + border-radius: 3px; + padding: 0.1rem 0.4rem; + flex-shrink: 0; +} + +.draft-card-info { + flex: 1; + min-width: 0; +} + +.draft-card-name { + font-weight: 500; +} + +.draft-card-meta { + font-size: 0.82rem; + color: var(--text-dim); +} + +.grid-card-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.grid-card { + display: flex; + align-items: center; + gap: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + cursor: pointer; + transition: border-color 0.1s; +} + +.grid-card:hover { + border-color: #3d3d3d; +} + +.grid-card-preview { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.grid-card-preview-thumb { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: 2px; +} + +.grid-card-info { + flex: 1; + min-width: 0; +} + +.grid-card-name { + font-weight: 500; +} + +.grid-card-meta { + font-size: 0.82rem; + color: var(--text-dim); +} + +/* ===== FORM ROW PAIR ===== */ + +.form-row-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.ng-summary { + font-size: 0.85rem; + color: var(--text-dim); + min-height: 1.4em; + margin-bottom: 0.25rem; +} + +/* ===== PANEL MANAGER ===== */ + +.panel-manager { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.panel-manager-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.panel-manager-header h2 { + font-size: 1.2rem; + font-weight: 600; +} + +.pm-meta { + font-size: 0.85rem; + color: var(--text-dim); + margin-top: 0.2rem; +} + +.pm-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.panel-slot-grid { + display: grid; + gap: 0.4rem; +} + +.panel-slot { + background: var(--surface); + border: 1px dashed var(--border); + border-radius: 4px; + padding: 0.3rem; + cursor: pointer; + transition: border-color 0.15s; + display: flex; + flex-direction: column; + gap: 0.2rem; + user-select: none; +} + +.panel-slot:hover { + border-color: var(--accent); +} + +.panel-slot.configured { + border-style: solid; + border-color: #3d3d3d; +} + +.panel-slot.configured:hover { + border-color: var(--accent); +} + +.panel-slot-preview { + position: relative; + width: 100%; + aspect-ratio: 4 / 3; + overflow: hidden; + border-radius: 3px; + background: var(--bg); + display: flex; + align-items: center; + justify-content: center; +} + +.panel-slot-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.panel-slot-thumb[hidden] { + display: none; +} + +.panel-slot-empty-icon { + position: absolute; + inset: 0; + background: + linear-gradient(to bottom right, transparent calc(50% - 0.5px), var(--border) calc(50% - 0.5px), var(--border) calc(50% + 0.5px), transparent calc(50% + 0.5px)), + linear-gradient(to bottom left, transparent calc(50% - 0.5px), var(--border) calc(50% - 0.5px), var(--border) calc(50% + 0.5px), transparent calc(50% + 0.5px)); +} + +.panel-slot-empty-icon[hidden] { + display: none; +} + +.panel-slot-label { + font-size: 0.72rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.panel-slot-range { + font-size: 0.65rem; + color: var(--text-faint); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ===== GRID SETUP INFO ===== */ + +.gs-panel-info { + font-size: 0.85rem; + color: var(--text-dim); + padding: 0.5rem 0.75rem; + background: var(--bg); + border-radius: 4px; + border-left: 3px solid var(--accent); + line-height: 1.5; +} + +/* ===== GRID SETUP ===== */ + +.grid-setup { + display: flex; + gap: 1.5rem; + align-items: flex-start; +} + +.grid-setup-left { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.grid-canvas { + display: block; + max-width: 100%; + border: 1px solid var(--border); + border-radius: 4px; + cursor: crosshair; + touch-action: none; +} + +.grid-setup-hint { + font-size: 0.82rem; + color: var(--text-dim); +} + +.grid-setup-right { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.setup-title { + font-size: 1.05rem; + font-weight: 600; +} + +.setup-cell-size { + font-size: 0.82rem; + color: var(--text-dim); + min-height: 1.5em; +} + +.setup-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.setup-progress { + color: var(--text-dim); + font-size: 0.9rem; +} + +/* ===== GRID VIEWER ===== */ + +.grid-viewer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.viewer-name { + font-size: 1.2rem; + font-weight: 600; +} + +.viewer-meta { + font-size: 0.85rem; + color: var(--text-dim); + margin-top: 0.15rem; +} + +.grid-viewer-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.grid-cells { + display: grid; + gap: 4px; +} + +.grid-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.grid-cell-img-wrap { + position: relative; + width: 100%; + aspect-ratio: 1; +} + +.grid-cell-img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 3px; + border: 1px solid var(--border); + display: block; + cursor: pointer; + transition: border-color 0.1s; +} + +.grid-cell-img:hover { + border-color: var(--accent); +} + +.grid-cell.empty .grid-cell-img { + display: none; +} + +.grid-cell.empty .grid-cell-img-wrap { + background: var(--surface-raised); + border-radius: 3px; + border: 1px solid var(--border); +} + +.grid-cell.empty .grid-cell-img-wrap::after { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(to bottom right, + transparent calc(50% - 1px), + var(--border) calc(50% - 1px), + var(--border) calc(50% + 1px), + transparent calc(50% + 1px)), + linear-gradient(to bottom left, + transparent calc(50% - 1px), + var(--border) calc(50% - 1px), + var(--border) calc(50% + 1px), + transparent calc(50% + 1px)); +} + +.grid-cell-label { + font-size: 0.65rem; + color: var(--text-faint); + text-align: center; + font-family: var(--font-mono); +} diff --git a/public/templates.html b/public/templates.html index 785dc04..71f5a02 100644 --- a/public/templates.html +++ b/public/templates.html @@ -1,28 +1,21 @@ @@ -30,8 +23,86 @@ - @@ -80,25 +153,169 @@ Master field index — fields available for all components -
- Field name - Unit - Description - -
-
+ + + + + + + + +
Field nameUnitDescription
+ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -194,6 +411,63 @@ + + + + + +