Files
electronics-inventory/public/app.mjs
mikael-lovqvists-claude-agent 451b04ad03 Add PDF first-page thumbnails via pdftoppm
Generated at upload time, stored alongside the PDF in data/pdfs/.
Shown in the file picker (48px) and component detail view (80px).
Gracefully skipped if pdftoppm is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:40:43 +00:00

1951 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = [];
let all_pdfs = [];
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function load_all() {
const [cf, ci, cmp, gr, dr, sr, ct, pd] = 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(),
api.get_pdfs(),
]);
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;
all_pdfs = pd.pdfs;
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 = '';
});
// Linked files
const files_list = qs(content, '.detail-files-list');
async function save_file_ids(new_ids) {
const result = await api.update_component(comp.id, { file_ids: new_ids });
const idx = all_components.findIndex(c => c.id === comp.id);
if (idx !== -1) all_components[idx] = result.component;
render_detail_panel();
}
function rebuild_detail_files() {
const linked_files = all_pdfs.filter(p => (comp.file_ids ?? []).includes(p.id));
const rows = linked_files.map(pdf => {
const row = document.createElement('div');
row.className = 'detail-file-row';
if (pdf.thumb_filename) {
const thumb = document.createElement('img');
thumb.className = 'pdf-thumb';
thumb.src = `/pdf/${pdf.thumb_filename}`;
thumb.alt = '';
row.appendChild(thumb);
}
const a = document.createElement('a');
a.className = 'detail-file-link';
a.href = `/pdf/${pdf.filename}`;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = pdf.display_name;
const unlink_btn = document.createElement('button');
unlink_btn.type = 'button';
unlink_btn.className = 'btn-icon btn-danger';
unlink_btn.textContent = '✕';
unlink_btn.title = 'Unlink file';
unlink_btn.addEventListener('click', () => {
const new_ids = (comp.file_ids ?? []).filter(id => id !== pdf.id);
save_file_ids(new_ids);
});
row.append(a, unlink_btn);
return row;
});
if (rows.length === 0) {
const note = document.createElement('p');
note.className = 'detail-empty-note';
note.textContent = 'No files linked.';
files_list.replaceChildren(note);
} else {
files_list.replaceChildren(...rows);
}
}
rebuild_detail_files();
qs(content, '.detail-link-file-btn').addEventListener('click', () => {
open_file_picker((pdf) => {
const new_ids = [...new Set([...(comp.file_ids ?? []), pdf.id])];
save_file_ids(new_ids);
});
});
// 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 =
`<strong>${d.name}</strong> — Panel ${pi + 1} of ${total}<br>` +
`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
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Dialog: File picker
// ---------------------------------------------------------------------------
let file_picker_callback = null;
function open_file_picker(on_select) {
file_picker_callback = on_select;
const dlg = document.getElementById('dialog-file-picker');
render_file_picker_list();
dlg.showModal();
}
function render_file_picker_list() {
const dlg = document.getElementById('dialog-file-picker');
const list_el = qs(dlg, '#fp-list');
list_el.replaceChildren(...all_pdfs.map(pdf => {
const row = document.createElement('div');
row.className = 'fp-row';
if (pdf.thumb_filename) {
const thumb = document.createElement('img');
thumb.className = 'fp-thumb';
thumb.src = `/pdf/${pdf.thumb_filename}`;
thumb.alt = '';
row.appendChild(thumb);
}
const name_el = document.createElement('span');
name_el.className = 'fp-name';
name_el.textContent = pdf.display_name;
const rename_btn = document.createElement('button');
rename_btn.type = 'button';
rename_btn.className = 'btn btn-secondary btn-sm';
rename_btn.textContent = 'Rename';
rename_btn.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'text';
input.className = 'fp-rename-input';
input.value = pdf.display_name;
name_el.replaceWith(input);
rename_btn.textContent = 'Save';
input.focus();
input.select();
const do_rename = async () => {
const new_name = input.value.trim();
if (!new_name || new_name === pdf.display_name) {
render_file_picker_list();
return;
}
try {
const result = await api.rename_pdf(pdf.id, new_name);
const idx = all_pdfs.findIndex(p => p.id === pdf.id);
if (idx !== -1) all_pdfs[idx] = result.pdf;
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
render_file_picker_list();
} catch (err) {
alert(err.message);
}
};
rename_btn.onclick = do_rename;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { do_rename(); }
if (e.key === 'Escape') { render_file_picker_list(); }
});
});
const select_btn = document.createElement('button');
select_btn.type = 'button';
select_btn.className = 'btn btn-primary btn-sm';
select_btn.textContent = 'Select';
select_btn.addEventListener('click', () => {
file_picker_callback?.(pdf);
dlg.close();
});
const del_btn = document.createElement('button');
del_btn.type = 'button';
del_btn.className = 'btn btn-danger btn-sm';
del_btn.textContent = '✕';
del_btn.title = 'Delete file';
del_btn.addEventListener('click', async () => {
if (!confirm(`Delete "${pdf.display_name}"? This cannot be undone.`)) return;
await api.delete_pdf(pdf.id);
all_pdfs = all_pdfs.filter(p => p.id !== pdf.id);
render_file_picker_list();
});
row.append(name_el, rename_btn, select_btn, del_btn);
return row;
}));
if (all_pdfs.length === 0) {
const note = document.createElement('p');
note.className = 'section-empty-note';
note.textContent = 'No files uploaded yet.';
list_el.replaceChildren(note);
}
}
// ---------------------------------------------------------------------------
// 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', 't-dialog-file-picker']) {
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('fp-cancel').addEventListener('click', () => {
document.getElementById('dialog-file-picker').close();
});
qs(document.getElementById('dialog-file-picker'), '#fp-upload-btn').addEventListener('click', async () => {
const file_input = document.getElementById('fp-file-input');
const name_input = document.getElementById('fp-upload-name');
const file = file_input.files[0];
if (!file) return;
try {
const result = await api.upload_pdf(file, name_input.value.trim() || null);
all_pdfs.push(result.pdf);
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
file_input.value = '';
name_input.value = '';
render_file_picker_list();
} catch (err) {
alert(err.message);
}
});
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();