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 // --------------------------------------------------------------------------- let section = 'components'; let all_components = []; let all_fields = []; 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 = []; let all_templates = []; // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- async function load_all() { const [cf, ci, cmp, gr, dr, sr, ct] = await Promise.all([ api.get_fields(), api.get_inventory(), api.get_components(), api.get_grids(), api.get_grid_drafts(), api.get_source_images(), api.get_component_templates(), ]); all_fields = cf.fields; all_inventory = ci.entries; all_components = cmp.components; all_grids = gr.grids; all_drafts = dr.drafts; all_sources = sr.sources; all_templates = ct.templates; compile_templates(); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const LOCATION_TYPE_LABEL = { physical: 'Physical', bom: 'BOM', digital: 'Digital', grid: 'Grid' }; function ref_label_for_type(type) { if (type === 'physical') return 'Location (drawer, bin, shelf…)'; if (type === 'bom') return 'Document / project name'; if (type === 'grid') return 'Grid cell'; return 'Note / description'; } function grid_cell_label(entry) { const grid = all_grids.find(g => g.id === entry.grid_id); if (!grid) { return `Grid cell (R${(entry.grid_row ?? 0) + 1}C${(entry.grid_col ?? 0) + 1})`; } return `${grid.name} R${(entry.grid_row ?? 0) + 1}C${(entry.grid_col ?? 0) + 1}`; } // --------------------------------------------------------------------------- // Component template engine // --------------------------------------------------------------------------- let compiled_formatters = []; // [{ id, name, fn }] function compile_templates() { compiled_formatters = []; for (const tmpl of all_templates) { if (!tmpl.formatter?.trim()) continue; try { // eslint-disable-next-line no-new-func const fn = new Function('c', `"use strict"; return (${tmpl.formatter})(c);`); compiled_formatters.push({ id: tmpl.id, name: tmpl.name, fn }); } catch (err) { console.warn(`Template "${tmpl.name}" failed to compile:`, err); } } } // Build a version of the component where c.fields is keyed by field name // instead of field ID, so formatters can use c.fields?.resistance etc. function named_fields_comp(comp) { const fields = {}; for (const [fid, val] of Object.entries(comp.fields ?? {})) { const def = field_by_id(fid); if (def) { fields[def.name] = val; } } return { ...comp, fields }; } function component_display_name(comp) { if (!compiled_formatters.length) return comp.name; const c = named_fields_comp(comp); for (const { fn } of compiled_formatters) { try { const result = fn(c); if (result != null && result !== '') return String(result); } catch (_) { // formatter threw — skip it } } return comp.name; } // --------------------------------------------------------------------------- function inventory_for_component(component_id) { return all_inventory.filter(e => e.component_id === component_id); } function component_by_id(id) { return all_components.find(c => c.id === id); } function field_by_id(id) { return all_fields.find(f => f.id === id); } function matches_search(component, query) { if (!query) return true; const q = query.toLowerCase(); if (component.name.toLowerCase().includes(q)) return true; if (component.description.toLowerCase().includes(q)) return true; for (const val of Object.values(component.fields ?? {})) { if (String(val).toLowerCase().includes(q)) return true; } for (const entry of inventory_for_component(component.id)) { if (entry.location_ref.toLowerCase().includes(q)) return true; } return false; } function matches_inventory_search(entry, query, type_filter) { if (type_filter && entry.location_type !== type_filter) return false; if (!query) return true; const q = query.toLowerCase(); const comp = component_by_id(entry.component_id); if (comp && comp.name.toLowerCase().includes(q)) return true; if (entry.location_ref.toLowerCase().includes(q)) return true; if (entry.notes.toLowerCase().includes(q)) return true; return false; } // --------------------------------------------------------------------------- // 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() { const main = document.getElementById('main'); let section_el = document.getElementById('section-components'); if (!section_el) { const frag = document.getElementById('t-section-components').content.cloneNode(true); main.replaceChildren(frag); section_el = document.getElementById('section-components'); qs(section_el, '#component-search').addEventListener('input', (e) => { component_search = e.target.value; render_component_list(); }); 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() { const list_el = document.getElementById('component-list'); const query = component_search.trim(); const visible = all_components.filter(c => matches_search(c, query)); if (visible.length === 0) { const el = clone('t-empty-block'); el.textContent = query ? 'No components match your search.' : 'No components yet. Add one!'; list_el.replaceChildren(el); return; } list_el.replaceChildren(...visible.map(build_component_row)); } function build_component_row(comp) { const row = clone('t-component-row'); set_text(row, '.component-name', component_display_name(comp)); const tags_el = qs(row, '.component-tags'); const field_entries = Object.entries(comp.fields ?? {}); if (field_entries.length > 0) { tags_el.replaceChildren(...field_entries.slice(0, 4).map(([fid, val]) => { const tag = clone('t-field-tag'); const def = field_by_id(fid); set_text(tag, '.tag-name', def ? def.name : fid); set_text(tag, '.tag-value', def?.unit ? `${val} ${def.unit}` : String(val)); return tag; })); } if (comp.id === selected_component_id) { row.classList.add('selected'); } 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', component_display_name(comp)); 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 "${component_display_name(comp)}"? Inventory entries will become orphaned.`, async () => { await api.delete_component(comp.id); all_components = all_components.filter(c => c.id !== comp.id); selected_component_id = null; render(); } )); // 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_type === 'grid' ? grid_cell_label(entry) : (entry.location_ref || '—')); set_text(el, '.detail-inv-qty', entry.quantity ? `×${entry.quantity}` : ''); set_text(el, '.detail-inv-notes', entry.notes || ''); if (entry.location_type === 'grid' && entry.grid_id) { const goto_btn = qs(el, '.detail-inv-goto-grid'); goto_btn.hidden = false; goto_btn.addEventListener('click', () => navigate(`/grids/viewer/${entry.grid_id}`)); // Show the grid cell image as a read-only thumbnail const grid = all_grids.find(g => g.id === entry.grid_id); const cell_filename = grid?.cells?.[entry.grid_row]?.[entry.grid_col]; if (cell_filename) { const thumb = document.createElement('a'); thumb.className = 'thumb-link cell-thumb-link'; thumb.href = `/img/${cell_filename}`; thumb.target = '_blank'; thumb.rel = 'noopener'; const img = document.createElement('img'); img.className = 'thumb-img'; img.src = `/img/${cell_filename}`; img.alt = 'Grid cell'; thumb.appendChild(img); qs(el, '.inv-image-grid').before(thumb); } } // 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; } // --------------------------------------------------------------------------- // Render: Inventory section // --------------------------------------------------------------------------- function render_inventory() { const main = document.getElementById('main'); let section_el = document.getElementById('section-inventory'); if (!section_el) { const frag = document.getElementById('t-section-inventory').content.cloneNode(true); main.replaceChildren(frag); section_el = document.getElementById('section-inventory'); qs(section_el, '#inventory-search').addEventListener('input', (e) => { inventory_search = e.target.value; render_inventory_list(); }); qs(section_el, '#inventory-type-filter').addEventListener('change', (e) => { inventory_type_filter = e.target.value; render_inventory_list(); }); qs(section_el, '#btn-add-inventory').addEventListener('click', () => open_inventory_dialog()); } qs(section_el, '#inventory-search').value = inventory_search; qs(section_el, '#inventory-type-filter').value = inventory_type_filter; render_inventory_list(); } function render_inventory_list() { const list_el = document.getElementById('inventory-list'); const visible = all_inventory.filter(e => matches_inventory_search(e, inventory_search.trim(), inventory_type_filter)); if (visible.length === 0) { 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; } list_el.replaceChildren(...visible.map(build_inventory_row)); } function build_inventory_row(entry) { const row = clone('t-inventory-row'); const comp = component_by_id(entry.component_id); set_text(row, '.inv-component-name', comp ? component_display_name(comp) : '(deleted component)'); const pill = document.createElement('span'); pill.className = `type-pill type-${entry.location_type}`; pill.textContent = LOCATION_TYPE_LABEL[entry.location_type] ?? entry.location_type; qs(row, '.inv-type-badge').replaceChildren(pill); set_text(row, '.inv-location-ref', entry.location_type === 'grid' ? grid_cell_label(entry) : entry.location_ref); set_text(row, '.inv-quantity', entry.quantity); set_text(row, '.inv-notes', entry.notes); qs(row, '.btn-edit').addEventListener('click', () => open_inventory_dialog(entry)); qs(row, '.btn-delete').addEventListener('click', () => confirm_delete( `Delete this inventory entry (${LOCATION_TYPE_LABEL[entry.location_type]}: ${entry.location_type === 'grid' ? grid_cell_label(entry) : (entry.location_ref || '—')})?`, async () => { await api.delete_inventory(entry.id); all_inventory = all_inventory.filter(e => e.id !== entry.id); render_inventory_list(); } )); return row; } // --------------------------------------------------------------------------- // Render: Fields section // --------------------------------------------------------------------------- function render_fields() { const main = document.getElementById('main'); let section_el = document.getElementById('section-fields'); if (!section_el) { const frag = document.getElementById('t-section-fields').content.cloneNode(true); main.replaceChildren(frag); section_el = document.getElementById('section-fields'); qs(section_el, '#btn-add-field').addEventListener('click', () => open_field_dialog()); } render_field_list(); } function render_field_list() { const list_el = document.getElementById('field-list'); if (all_fields.length === 0) { 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; } list_el.replaceChildren(...all_fields.map(build_field_row)); } function build_field_row(fdef) { const row = clone('t-field-row'); set_text(row, '.fdef-name', fdef.name); set_text(row, '.fdef-unit', fdef.unit || '—'); set_text(row, '.fdef-description', fdef.description || ''); qs(row, '.btn-edit').addEventListener('click', () => open_field_dialog(fdef)); qs(row, '.btn-delete').addEventListener('click', () => confirm_delete( `Delete field definition "${fdef.name}"? This will not remove values already stored on components.`, async () => { await api.delete_field(fdef.id); all_fields = all_fields.filter(f => f.id !== fdef.id); render_field_list(); } )); return row; } // --------------------------------------------------------------------------- // Render: Templates section // --------------------------------------------------------------------------- let template_dialog = null; let template_dialog_callback = null; function render_templates() { const main = document.getElementById('main'); let section_el = document.getElementById('section-templates'); if (!section_el) { const frag = document.getElementById('t-section-templates').content.cloneNode(true); main.replaceChildren(frag); section_el = document.getElementById('section-templates'); qs(section_el, '#btn-add-template').addEventListener('click', () => open_template_dialog()); } render_template_list(); } function render_template_list() { const list_el = document.getElementById('template-list'); if (!list_el) return; if (all_templates.length === 0) { const note = document.createElement('p'); note.className = 'section-empty-note'; note.textContent = 'No templates yet. Add one to automatically format component display names.'; list_el.replaceChildren(note); return; } list_el.replaceChildren(...all_templates.map(tmpl => { const card = clone('t-template-card'); set_text(card, '.template-card-name', tmpl.name); qs(card, '.template-card-formatter').textContent = tmpl.formatter || '(empty)'; qs(card, '.btn-edit').addEventListener('click', () => open_template_dialog(tmpl)); qs(card, '.btn-delete').addEventListener('click', () => confirm_delete( `Delete template "${tmpl.name}"?`, async () => { await api.delete_component_template(tmpl.id); all_templates = all_templates.filter(t => t.id !== tmpl.id); compile_templates(); render_template_list(); } )); return card; })); } function open_template_dialog(tmpl = null) { if (!template_dialog) { const frag = document.getElementById('t-dialog-template').content.cloneNode(true); document.body.appendChild(frag); template_dialog = document.getElementById('dialog-template'); qs(template_dialog, '#tmpl-cancel').addEventListener('click', () => template_dialog.close()); document.getElementById('form-template').addEventListener('submit', async (e) => { e.preventDefault(); try { await template_dialog_callback?.(); template_dialog.close(); compile_templates(); render_template_list(); render(); // refresh component names everywhere } catch (err) { alert(`Error: ${err.message}`); } }); // Live preview qs(template_dialog, '#tmpl-formatter').addEventListener('input', update_tmpl_preview); qs(template_dialog, '#tmpl-test-data').addEventListener('input', update_tmpl_preview); } qs(template_dialog, '.dialog-title').textContent = tmpl ? 'Edit template' : 'Add template'; qs(template_dialog, '#tmpl-name').value = tmpl?.name ?? ''; qs(template_dialog, '#tmpl-formatter').value = tmpl?.formatter ?? ''; qs(template_dialog, '#tmpl-test-data').value = ''; update_tmpl_preview(); template_dialog_callback = async () => { const body = { name: qs(template_dialog, '#tmpl-name').value.trim(), formatter: qs(template_dialog, '#tmpl-formatter').value.trim(), }; if (tmpl) { const result = await api.update_component_template(tmpl.id, body); const idx = all_templates.findIndex(t => t.id === tmpl.id); if (idx !== -1) all_templates[idx] = result.template; } else { const result = await api.create_component_template(body); all_templates.push(result.template); } }; template_dialog.showModal(); qs(template_dialog, '#tmpl-name').focus(); } function update_tmpl_preview() { if (!template_dialog) return; const preview_el = qs(template_dialog, '#tmpl-preview'); const formatter_str = qs(template_dialog, '#tmpl-formatter').value.trim(); if (!formatter_str) { preview_el.textContent = '—'; return; } // Build the component to preview against let preview_comp; const test_data_str = qs(template_dialog, '#tmpl-test-data').value.trim(); if (test_data_str) { try { // eslint-disable-next-line no-new-func const test_fields = new Function(test_data_str)(); preview_comp = { name: '(test)', fields: test_fields ?? {} }; } catch (err) { preview_el.textContent = `Test data error: ${err.message}`; return; } } else { const sample = all_components[0]; if (!sample) { preview_el.textContent = '(no components to preview)'; return; } preview_comp = named_fields_comp(sample); } try { // eslint-disable-next-line no-new-func const fn = new Function('c', `"use strict"; return (${formatter_str})(c);`); const result = fn(preview_comp); preview_el.textContent = result != null ? String(result) : `null — falls back to "${preview_comp.name}"`; } catch (err) { preview_el.textContent = `Formatter error: ${err.message}`; } } // --------------------------------------------------------------------------- // 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'); } )); const cells_el = document.getElementById('grid-cells'); const gap = 4; const available = cells_el.parentElement.clientWidth; const cell_px = Math.floor((available - gap * (grid.cols - 1)) / grid.cols); cells_el.style.gridTemplateColumns = `repeat(${grid.cols}, ${cell_px}px)`; 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}`; } else { cell.classList.add('empty'); } set_text(cell, '.grid-cell-label', `R${row + 1}C${col + 1}`); cell.addEventListener('click', (e) => open_cell_inventory(grid, row, col, e)); return cell; }); cells_el.replaceChildren(...all_cells); } function open_cell_inventory(grid, row, col, e) { // Remove any existing overlay document.getElementById('cell-inventory-overlay')?.remove(); const frag = document.getElementById('t-cell-inventory').content.cloneNode(true); document.body.appendChild(frag); const overlay = document.getElementById('cell-inventory-overlay'); // Position near click const x = Math.min(e.clientX + 8, window.innerWidth - 340); const y = Math.min(e.clientY + 8, window.innerHeight - 200); overlay.style.left = x + 'px'; overlay.style.top = y + 'px'; qs(overlay, '.cell-inventory-title').textContent = `${grid.name} R${row + 1}C${col + 1}`; const entries = all_inventory.filter(inv => inv.location_type === 'grid' && inv.grid_id === grid.id && inv.grid_row === row && inv.grid_col === col ); const list_el = qs(overlay, '.cell-inventory-list'); if (entries.length === 0) { const empty = document.createElement('div'); empty.className = 'cell-inv-empty'; empty.textContent = 'Nothing stored here yet'; list_el.appendChild(empty); } else { entries.forEach(entry => { const comp = component_by_id(entry.component_id); const item = document.createElement('div'); item.className = 'cell-inv-item cell-inv-item-link'; const name_span = document.createElement('span'); name_span.textContent = comp ? component_display_name(comp) : '?'; const qty_span = document.createElement('span'); qty_span.className = 'cell-inv-qty'; qty_span.textContent = entry.quantity || ''; item.append(name_span, qty_span); if (comp) { item.addEventListener('click', () => { overlay.remove(); selected_component_id = comp.id; navigate('/components'); }); } list_el.appendChild(item); }); } qs(overlay, '#cell-inv-close').addEventListener('click', () => overlay.remove()); qs(overlay, '#cell-inv-add').addEventListener('click', () => { overlay.remove(); open_inventory_dialog(null, null, { grid_id: grid.id, grid_row: row, grid_col: col }); }); // Close on outside click setTimeout(() => { document.addEventListener('click', function handler(ev) { if (!overlay.contains(ev.target)) { overlay.remove(); document.removeEventListener('click', handler); } }); }, 0); } // --------------------------------------------------------------------------- // Dialog: Component // --------------------------------------------------------------------------- let component_dialog_callback = null; function open_component_dialog(comp = null) { const dlg = document.getElementById('dialog-component'); const title = qs(dlg, '.dialog-title'); const name_input = qs(dlg, '#c-name'); const desc_input = qs(dlg, '#c-description'); 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'; name_input.value = comp?.name ?? ''; desc_input.value = comp?.description ?? ''; 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 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 = def ? def.name : fid; if (def?.unit) { const unit_span = document.createElement('span'); unit_span.className = 'c-field-unit-hint'; unit_span.textContent = ` [${def.unit}]`; label_el.appendChild(unit_span); } const input_el = document.createElement('input'); input_el.type = 'text'; input_el.className = 'c-field-value'; 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)); const remove_btn = document.createElement('button'); remove_btn.type = 'button'; remove_btn.className = 'btn-icon btn-danger'; remove_btn.textContent = '✕'; remove_btn.title = 'Remove field value'; remove_btn.addEventListener('click', () => { active_fields.delete(fid); rebuild_field_rows(); rebuild_add_select(); }); row_el.appendChild(label_el); row_el.appendChild(input_el); row_el.appendChild(remove_btn); return row_el; })); } function rebuild_add_select() { const available = all_fields.filter(f => !active_fields.has(f.id)); add_field_sel.replaceChildren( Object.assign(document.createElement('option'), { value: '', textContent: '— add a field —' }), ...available.map(f => Object.assign(document.createElement('option'), { value: f.id, textContent: f.name + (f.unit ? ` [${f.unit}]` : ''), })) ); } rebuild_field_rows(); rebuild_add_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) => { const fid = e.target.value; if (!fid) return; active_fields.set(fid, ''); rebuild_field_rows(); rebuild_add_select(); const inputs = field_rows_el.querySelectorAll(`[data-field_id="${fid}"]`); if (inputs.length) inputs[inputs.length - 1].focus(); }; add_field_sel.addEventListener('change', add_field_sel._change_handler); const new_field_btn = qs(dlg, '#c-new-field'); new_field_btn.onclick = () => { const known_ids = new Set(all_fields.map(f => f.id)); open_field_dialog(null); document.getElementById('dialog-field').addEventListener('close', () => { const new_field = all_fields.find(f => !known_ids.has(f.id)); rebuild_add_select(); if (new_field) { active_fields.set(new_field.id, ''); rebuild_field_rows(); rebuild_add_select(); const inputs = field_rows_el.querySelectorAll(`[data-field_id="${new_field.id}"]`); if (inputs.length) inputs[inputs.length - 1].focus(); } }, { once: true }); }; component_dialog_callback = async () => { const name = name_input.value.trim(); if (!name) return; const fields = {}; for (const [fid, val] of active_fields.entries()) { if (val.trim()) fields[fid] = val.trim(); } const body = { name, description: desc_input.value.trim(), fields }; if (comp) { const result = await api.update_component(comp.id, body); const idx = all_components.findIndex(c => c.id === comp.id); if (idx !== -1) all_components[idx] = result.component; } else { 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; } }; dlg.showModal(); name_input.focus(); } // --------------------------------------------------------------------------- // Dialog: Inventory entry // --------------------------------------------------------------------------- let inventory_dialog_callback = null; function open_inventory_dialog(entry = null, default_component_id = null, default_grid_cell = null) { const dlg = document.getElementById('dialog-inventory'); const title = qs(dlg, '.dialog-title'); const comp_sel = qs(dlg, '#i-component'); const type_sel = qs(dlg, '#i-type'); const ref_input = qs(dlg, '#i-ref'); const ref_label = qs(dlg, '#i-ref-label'); const qty_input = qs(dlg, '#i-qty'); const notes_input = qs(dlg, '#i-notes'); const new_comp_btn = qs(dlg, '#i-new-component'); const grid_row_div = qs(dlg, '#i-grid-row'); const grid_sel = qs(dlg, '#i-grid-select'); const row_num_input = qs(dlg, '#i-grid-row-num'); const col_num_input = qs(dlg, '#i-grid-col-num'); title.textContent = entry ? 'Edit inventory entry' : 'Add inventory entry'; comp_sel.replaceChildren( Object.assign(document.createElement('option'), { value: '', textContent: '— select component —' }), ...all_components.map(c => Object.assign(document.createElement('option'), { value: c.id, textContent: c.name, })) ); comp_sel.value = entry?.component_id ?? default_component_id ?? ''; const effective_grid_cell = default_grid_cell ?? (entry?.location_type === 'grid' ? { grid_id: entry.grid_id, grid_row: entry.grid_row, grid_col: entry.grid_col } : null); if (effective_grid_cell) { type_sel.value = 'grid'; } else { type_sel.value = entry?.location_type ?? 'physical'; } ref_input.value = entry?.location_ref ?? ''; qty_input.value = entry?.quantity ?? ''; notes_input.value = entry?.notes ?? ''; // Populate grid selector grid_sel.replaceChildren( Object.assign(document.createElement('option'), { value: '', textContent: '— select grid —' }), ...all_grids.map(g => Object.assign(document.createElement('option'), { value: g.id, textContent: g.name, })) ); if (effective_grid_cell?.grid_id) { grid_sel.value = effective_grid_cell.grid_id; } row_num_input.value = effective_grid_cell?.grid_row != null ? effective_grid_cell.grid_row + 1 : 1; col_num_input.value = effective_grid_cell?.grid_col != null ? effective_grid_cell.grid_col + 1 : 1; function update_ref_label() { ref_label.textContent = ref_label_for_type(type_sel.value); const is_grid = type_sel.value === 'grid'; grid_row_div.hidden = !is_grid; ref_input.closest('.form-row').hidden = is_grid; } update_ref_label(); const old_type_handler = type_sel._change_handler; if (old_type_handler) type_sel.removeEventListener('change', old_type_handler); type_sel._change_handler = update_ref_label; type_sel.addEventListener('change', type_sel._change_handler); new_comp_btn.onclick = () => { const known_ids = new Set(all_components.map(c => c.id)); open_component_dialog(null); document.getElementById('dialog-component').addEventListener('close', () => { // Rebuild the selector and select the newly created component (if any) comp_sel.replaceChildren( Object.assign(document.createElement('option'), { value: '', textContent: '— select component —' }), ...all_components.map(c => Object.assign(document.createElement('option'), { value: c.id, textContent: c.name, })) ); const new_comp = all_components.find(c => !known_ids.has(c.id)); if (new_comp) comp_sel.value = new_comp.id; }, { once: true }); }; inventory_dialog_callback = async () => { const is_grid = type_sel.value === 'grid'; const body = { component_id: comp_sel.value, location_type: type_sel.value, location_ref: is_grid ? '' : ref_input.value.trim(), quantity: qty_input.value.trim(), notes: notes_input.value.trim(), grid_id: is_grid ? (grid_sel.value || null) : null, grid_row: is_grid ? parseInt(row_num_input.value) - 1 : null, grid_col: is_grid ? parseInt(col_num_input.value) - 1 : null, }; if (entry) { const result = await api.update_inventory(entry.id, body); const idx = all_inventory.findIndex(e => e.id === entry.id); if (idx !== -1) all_inventory[idx] = result.entry; } else { const result = await api.create_inventory(body); all_inventory.push(result.entry); } }; dlg.showModal(); comp_sel.focus(); } // --------------------------------------------------------------------------- // Dialog: Field definition // --------------------------------------------------------------------------- let field_dialog_callback = null; function open_field_dialog(fdef = null) { const dlg = document.getElementById('dialog-field'); const title = qs(dlg, '.dialog-title'); const name_input = qs(dlg, '#f-name'); const unit_input = qs(dlg, '#f-unit'); const desc_input = qs(dlg, '#f-description'); title.textContent = fdef ? 'Edit field' : 'Add field'; name_input.value = fdef?.name ?? ''; unit_input.value = fdef?.unit ?? ''; desc_input.value = fdef?.description ?? ''; field_dialog_callback = async () => { const body = { name: name_input.value.trim(), unit: unit_input.value.trim(), description: desc_input.value.trim(), }; if (fdef) { const result = await api.update_field(fdef.id, body); const idx = all_fields.findIndex(f => f.id === fdef.id); if (idx !== -1) all_fields[idx] = result.field; all_fields.sort((a, b) => a.name.localeCompare(b.name)); } else { const result = await api.create_field(body); all_fields.push(result.field); all_fields.sort((a, b) => a.name.localeCompare(b.name)); } }; dlg.showModal(); name_input.focus(); } // --------------------------------------------------------------------------- // Dialog: Confirm delete // --------------------------------------------------------------------------- let confirm_callback = null; function confirm_delete(message, on_confirm) { const dlg = document.getElementById('dialog-confirm'); document.getElementById('confirm-message').textContent = message; confirm_callback = 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 === 'templates') { section = 'templates'; } 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(); else if (section === 'templates') render_templates(); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { const html = await fetch('/templates.html').then(r => r.text()); document.body.insertAdjacentHTML('beforeend', html); for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm']) { document.body.appendChild(document.getElementById(id).content.cloneNode(true)); } 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}`); } }); document.getElementById('c-cancel').addEventListener('click', () => { document.getElementById('dialog-component').close(); }); document.getElementById('form-inventory').addEventListener('submit', async (e) => { e.preventDefault(); try { await inventory_dialog_callback?.(); document.getElementById('dialog-inventory').close(); render(); } catch (err) { alert(`Error: ${err.message}`); } }); document.getElementById('i-cancel').addEventListener('click', () => { document.getElementById('dialog-inventory').close(); }); document.getElementById('form-field').addEventListener('submit', async (e) => { e.preventDefault(); try { await field_dialog_callback?.(); document.getElementById('dialog-field').close(); render(); } catch (err) { alert(`Error: ${err.message}`); } }); document.getElementById('f-cancel').addEventListener('click', () => { document.getElementById('dialog-field').close(); }); document.getElementById('confirm-ok').addEventListener('click', async () => { try { await confirm_callback?.(); document.getElementById('dialog-confirm').close(); render(); } catch (err) { alert(`Error: ${err.message}`); } }); document.getElementById('confirm-cancel').addEventListener('click', () => { document.getElementById('dialog-confirm').close(); }); document.querySelectorAll('.nav-btn').forEach(btn => { btn.addEventListener('click', () => navigate('/' + btn.dataset.section)); }); window.addEventListener('popstate', () => { parse_url(); render(); }); await load_all(); parse_url(); render(); } init();