- Upload dialog now has distinct display name + filename fields, both pre-filled from the uploaded file but independently editable - Rename in file picker shows and edits both display name and filename separately - Filename conflict checked against both KV store and disk (via rename_no_replace) - Display name and filename are fully independent — no longer derived from each other - Add find_pdf_references() helper in storage.mjs for future use - CSS: fp-name-wrap shows display name + dim monospace filename below it; rename mode stacks two inputs; fp-field-label for upload form labels Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2169 lines
74 KiB
JavaScript
2169 lines
74 KiB
JavaScript
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 =
|
||
`<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');
|
||
}
|
||
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_wrap = document.createElement('div');
|
||
name_wrap.className = 'fp-name-wrap';
|
||
const name_el = document.createElement('span');
|
||
name_el.className = 'fp-name';
|
||
name_el.textContent = pdf.display_name;
|
||
const filename_el = document.createElement('span');
|
||
filename_el.className = 'fp-filename';
|
||
filename_el.textContent = pdf.filename;
|
||
name_wrap.append(name_el, filename_el);
|
||
|
||
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 name_input = document.createElement('input');
|
||
name_input.type = 'text';
|
||
name_input.className = 'fp-rename-input';
|
||
name_input.value = pdf.display_name;
|
||
name_input.placeholder = 'Display name';
|
||
|
||
const filename_input = document.createElement('input');
|
||
filename_input.type = 'text';
|
||
filename_input.className = 'fp-rename-input fp-rename-filename';
|
||
filename_input.value = pdf.filename;
|
||
filename_input.placeholder = 'filename.pdf';
|
||
filename_input.spellcheck = false;
|
||
|
||
name_wrap.replaceChildren(name_input, filename_input);
|
||
rename_btn.textContent = 'Save';
|
||
name_input.focus();
|
||
name_input.select();
|
||
|
||
const do_rename = async () => {
|
||
const new_display = name_input.value.trim();
|
||
const new_filename = filename_input.value.trim();
|
||
if (!new_display || !new_filename) { render_file_picker_list(); return; }
|
||
if (new_display === pdf.display_name && new_filename === pdf.filename) {
|
||
render_file_picker_list();
|
||
return;
|
||
}
|
||
try {
|
||
const result = await api.rename_pdf(pdf.id, new_display, new_filename);
|
||
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;
|
||
[name_input, filename_input].forEach(inp => inp.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_wrap, 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();
|
||
});
|
||
|
||
document.getElementById('fp-file-input').addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const name_input = document.getElementById('fp-upload-name');
|
||
const filename_input = document.getElementById('fp-upload-filename');
|
||
if (!name_input.value.trim()) {
|
||
name_input.value = file.name.replace(/\.pdf$/i, '');
|
||
}
|
||
if (!filename_input.value.trim()) {
|
||
filename_input.value = file.name;
|
||
}
|
||
});
|
||
|
||
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 filename_input = document.getElementById('fp-upload-filename');
|
||
const file = file_input.files[0];
|
||
if (!file) return;
|
||
const display_name = name_input.value.trim();
|
||
const filename = filename_input.value.trim();
|
||
if (!display_name) { alert('Display name is required.'); return; }
|
||
if (!filename) { alert('Filename is required.'); return; }
|
||
try {
|
||
const result = await api.upload_pdf(file, display_name, filename);
|
||
all_pdfs.push(result.pdf);
|
||
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||
file_input.value = '';
|
||
name_input.value = '';
|
||
filename_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();
|