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 highlight_cell = null; // { row, col } — set when navigating from component detail
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 };
}
// Render a field value into a DOM element.
// Central place for all field-specific display logic (units, URLs, future integrations).
function render_field_value(el, val, def) {
const display = def?.unit ? `${val}${def.unit}` : String(val);
if (/^https?:\/\//i.test(val)) {
const a = document.createElement('a');
a.href = val;
a.textContent = display;
a.target = '_blank';
a.rel = 'noopener';
el.replaceChildren(a);
} else {
el.textContent = display;
}
}
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 words = query.toLowerCase().split(/\s+/).filter(Boolean);
const haystack = [
component.name,
component.description,
...Object.values(component.fields ?? {}).map(String),
...inventory_for_component(component.id).map(e => e.location_ref),
].join('\n').toLowerCase();
return words.every(w => haystack.includes(w));
}
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 = '#';
link.addEventListener('click', (e) => { e.preventDefault(); open_lightbox(`/img/${img_id}`); });
qs(thumb, '.thumb-img').src = `/img/${img_id}`;
qs(thumb, '.thumb-delete').addEventListener('click', () => on_delete(img_id));
return thumb;
}));
}
function open_lightbox(src) {
const lb = document.getElementById('lightbox');
document.getElementById('lightbox-img').src = src;
lb.hidden = false;
}
// ---------------------------------------------------------------------------
// 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');
const list_pane = document.getElementById('list-pane');
const resizer = document.getElementById('split-resizer');
const saved_width = localStorage.getItem('list-pane-width');
if (saved_width) { list_pane.style.width = saved_width + 'px'; }
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
resizer.classList.add('dragging');
const start_x = e.clientX;
const start_w = list_pane.getBoundingClientRect().width;
const on_move = (ev) => {
const w = Math.max(150, Math.min(start_w + ev.clientX - start_x, window.innerWidth * 0.6));
list_pane.style.width = w + 'px';
};
const on_up = () => {
resizer.classList.remove('dragging');
localStorage.setItem('list-pane-width', parseInt(list_pane.style.width));
document.removeEventListener('mousemove', on_move);
document.removeEventListener('mouseup', on_up);
};
document.addEventListener('mousemove', on_move);
document.addEventListener('mouseup', on_up);
});
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;
history.replaceState(null, '', `/components/${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);
render_field_value(qs(tag, '.tag-value'), val, def);
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;
history.replaceState(null, '', `/components/${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-duplicate-btn').addEventListener('click', async () => {
const result = await api.create_component({
name: comp.name,
description: comp.description,
fields: { ...comp.fields },
});
all_components.push(result.component);
all_components.sort((a, b) => a.name.localeCompare(b.name));
selected_component_id = result.component.id;
history.replaceState(null, '', `/components/${result.component.id}`);
render();
open_component_dialog(result.component);
});
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;
history.replaceState(null, '', '/components');
render();
}
));
// Fields
const fields_el = qs(content, '.detail-fields-list');
const field_entries = Object.entries(comp.fields ?? {}).sort(([a], [b]) => {
const na = field_by_id(a)?.name ?? a;
const nb = field_by_id(b)?.name ?? b;
return na.localeCompare(nb);
});
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);
render_field_value(qs(row, '.detail-field-value'), val, def);
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 = '';
thumb.style.cursor = 'zoom-in';
thumb.addEventListener('click', () => open_lightbox(`/pdf/${pdf.thumb_filename}`));
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-qty', entry.quantity ? `×${entry.quantity}` : '');
set_text(el, '.detail-inv-notes', entry.notes || '');
if (entry.location_type === 'grid' && entry.grid_id) {
const ref_el = qs(el, '.detail-inv-ref');
ref_el.textContent = grid_cell_label(entry);
ref_el.classList.add('detail-inv-ref-link');
ref_el.addEventListener('click', () => navigate(`/grids/viewer/${entry.grid_id}?row=${entry.grid_row}&col=${entry.grid_col}`));
// 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('div');
thumb.className = 'cell-thumb-preview';
thumb.addEventListener('click', () => open_lightbox(`/img/${cell_filename}`));
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);
const name_el = qs(row, '.inv-component-name');
name_el.textContent = comp ? component_display_name(comp) : '(deleted component)';
if (comp) {
name_el.classList.add('inv-component-link');
name_el.addEventListener('click', () => {
navigate(`/components/${comp.id}`);
});
}
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');
}
const label_el = qs(cell, '.grid-cell-label');
const count = all_inventory.filter(inv =>
inv.location_type === 'grid' && inv.grid_id === grid.id &&
inv.grid_row === row && inv.grid_col === col
).length;
if (count > 0) {
const badge = document.createElement('span');
badge.className = 'grid-cell-count';
badge.textContent = count;
label_el.appendChild(badge);
}
const index_span = document.createElement('span');
index_span.textContent = `R${row + 1}C${col + 1}`;
index_span.style.marginLeft = 'auto';
label_el.appendChild(index_span);
if (highlight_cell && highlight_cell.row === row && highlight_cell.col === col) {
cell.classList.add('highlighted');
}
cell.addEventListener('click', (e) => open_cell_inventory(grid, row, col, e));
return cell;
});
cells_el.replaceChildren(...all_cells);
if (highlight_cell) {
cells_el.querySelector('.grid-cell.highlighted')?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}
}
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('a');
item.className = 'cell-inv-item cell-inv-item-link';
if (comp) { item.href = `/components/${comp.id}`; }
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', (e) => {
e.preventDefault();
overlay.remove();
navigate(`/components/${comp.id}`);
});
}
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 = '';
thumb.style.cursor = 'zoom-in';
thumb.addEventListener('click', () => open_lightbox(`/pdf/${pdf.thumb_filename}`));
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() {
const sorted_entries = [...active_fields.entries()].sort(([a], [b]) => {
const na = field_by_id(a)?.name ?? a;
const nb = field_by_id(b)?.name ?? b;
return na.localeCompare(nb);
});
field_rows_el.replaceChildren(...sorted_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;
history.replaceState(null, '', `/components/${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 grid_visual = qs(dlg, '#i-grid-visual');
let picker_row = null;
let picker_col = null;
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: component_display_name(c),
}))
);
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; }
picker_row = effective_grid_cell?.grid_row ?? null;
picker_col = effective_grid_cell?.grid_col ?? null;
function rebuild_grid_visual() {
const grid = all_grids.find(g => g.id === grid_sel.value);
if (!grid) { grid_visual.replaceChildren(); return; }
grid_visual.style.gridTemplateColumns = `repeat(${grid.cols}, 64px)`;
const cells = [];
for (let r = 0; r < grid.rows; r++) {
for (let c = 0; c < grid.cols; c++) {
const cell = document.createElement('div');
cell.className = 'igv-cell';
if (r === picker_row && c === picker_col) cell.classList.add('igv-selected');
const filename = grid.cells?.[r]?.[c];
if (filename) {
const img = document.createElement('img');
img.src = `/img/${filename}`;
img.className = 'igv-img';
cell.appendChild(img);
}
cell.addEventListener('click', () => {
picker_row = r;
picker_col = c;
grid_visual.querySelectorAll('.igv-cell').forEach(el => el.classList.remove('igv-selected'));
cell.classList.add('igv-selected');
});
cells.push(cell);
}
}
grid_visual.replaceChildren(...cells);
}
rebuild_grid_visual();
const old_grid_handler = grid_sel._change_handler;
if (old_grid_handler) grid_sel.removeEventListener('change', old_grid_handler);
grid_sel._change_handler = () => { picker_row = null; picker_col = null; rebuild_grid_visual(); };
grid_sel.addEventListener('change', grid_sel._change_handler);
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 ? picker_row : null,
grid_col: is_grid ? picker_col : 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;
highlight_cell = null;
selected_component_id = null;
const qp = new URLSearchParams(window.location.search);
if (qp.has('row') && qp.has('col')) {
highlight_cell = { row: parseInt(qp.get('row')), col: parseInt(qp.get('col')) };
}
const [p0, p1, p2, p3, p4] = parts;
if (!p0 || p0 === 'components') {
section = 'components';
if (p1) selected_component_id = p1;
} 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));
});
// Lightbox
const lightbox = document.getElementById('lightbox');
lightbox.addEventListener('click', () => { lightbox.hidden = true; });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lightbox.hidden = true; });
// Maintenance menu
const maint_toggle = document.getElementById('maint-toggle');
const maint_dropdown = document.getElementById('maint-dropdown');
maint_toggle.addEventListener('click', (e) => {
e.stopPropagation();
maint_dropdown.hidden = !maint_dropdown.hidden;
});
document.addEventListener('click', () => { maint_dropdown.hidden = true; });
document.getElementById('maint-gen-thumbs').addEventListener('click', async () => {
maint_dropdown.hidden = true;
maint_toggle.textContent = '⏳';
maint_toggle.disabled = true;
try {
const result = await api.maintenance_pdf_thumbs();
const refreshed = await api.get_pdfs();
all_pdfs = refreshed.pdfs;
alert(`Generated ${result.generated} thumbnail(s) of ${result.total} PDF(s).`);
render();
} catch (err) {
alert(`Error: ${err.message}`);
} finally {
maint_toggle.textContent = '⚙';
maint_toggle.disabled = false;
}
});
window.addEventListener('popstate', () => { parse_url(); render(); });
await load_all();
parse_url();
render();
}
init();