diff --git a/lib/storage.mjs b/lib/storage.mjs index b7821d4..ddf265e 100644 --- a/lib/storage.mjs +++ b/lib/storage.mjs @@ -125,6 +125,28 @@ export function delete_source_image(id) { return store.delete(`s:${id}`); } +// --- Component templates --- + +export function list_component_templates() { + const result = []; + for (const [key] of store.data.entries()) { + if (key.startsWith('ct:')) result.push(store.get(key)); + } + return result.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function get_component_template(id) { + return store.get(`ct:${id}`) ?? null; +} + +export function set_component_template(tmpl) { + store.set(`ct:${tmpl.id}`, tmpl); +} + +export function delete_component_template(id) { + return store.delete(`ct:${id}`); +} + // --- Grid images --- export function list_grid_images() { diff --git a/public/app.mjs b/public/app.mjs index bddfc42..1d537c1 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -25,19 +25,21 @@ let grid_source_id = null; let grid_draft = null; // { id?, name, rows, cols, panel_rows, panel_cols, panels[], edit_grid_id? } let current_panel_idx = null; let all_drafts = []; +let all_templates = []; // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- async function load_all() { - const [cf, ci, cmp, gr, dr, sr] = await Promise.all([ + const [cf, ci, cmp, gr, dr, sr, ct] = await Promise.all([ api.get_fields(), api.get_inventory(), api.get_components(), api.get_grids(), api.get_grid_drafts(), api.get_source_images(), + api.get_component_templates(), ]); all_fields = cf.fields; all_inventory = ci.entries; @@ -45,6 +47,8 @@ async function load_all() { all_grids = gr.grids; all_drafts = dr.drafts; all_sources = sr.sources; + all_templates = ct.templates; + compile_templates(); } // --------------------------------------------------------------------------- @@ -66,6 +70,40 @@ function grid_cell_label(entry) { 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); + } + } +} + +function component_display_name(comp) { + for (const { fn } of compiled_formatters) { + try { + const result = fn(comp); + 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); } @@ -184,7 +222,7 @@ function render_component_list() { function build_component_row(comp) { const row = clone('t-component-row'); - set_text(row, '.component-name', comp.name); + set_text(row, '.component-name', component_display_name(comp)); const tags_el = qs(row, '.component-tags'); const field_entries = Object.entries(comp.fields ?? {}); @@ -229,11 +267,11 @@ function render_detail_panel() { const content = clone('t-detail-content'); // Header - set_text(content, '.detail-name', comp.name); + set_text(content, '.detail-name', component_display_name(comp)); set_text(content, '.detail-description', comp.description || ''); qs(content, '.detail-edit-btn').addEventListener('click', () => open_component_dialog(comp)); qs(content, '.detail-delete-btn').addEventListener('click', () => confirm_delete( - `Delete component "${comp.name}"? Inventory entries will become orphaned.`, + `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); @@ -308,6 +346,12 @@ function build_detail_inv_entry(entry) { set_text(el, '.detail-inv-qty', entry.quantity ? `×${entry.quantity}` : ''); set_text(el, '.detail-inv-notes', entry.notes || ''); + if (entry.location_type === 'grid' && entry.grid_id) { + const goto_btn = qs(el, '.detail-inv-goto-grid'); + goto_btn.hidden = false; + goto_btn.addEventListener('click', () => navigate(`/grids/viewer/${entry.grid_id}`)); + } + // Inventory entry images build_image_grid( qs(el, '.inv-image-grid'), @@ -389,7 +433,7 @@ function render_inventory_list() { function build_inventory_row(entry) { const row = clone('t-inventory-row'); const comp = component_by_id(entry.component_id); - set_text(row, '.inv-component-name', comp ? comp.name : '(deleted component)'); + set_text(row, '.inv-component-name', comp ? component_display_name(comp) : '(deleted component)'); const pill = document.createElement('span'); pill.className = `type-pill type-${entry.location_type}`; @@ -462,6 +506,116 @@ function build_field_row(fdef) { 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 against first component + qs(template_dialog, '#tmpl-formatter').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 ?? ''; + 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; } + const sample = all_components[0]; + if (!sample) { preview_el.textContent = '(no components to preview)'; return; } + try { + // eslint-disable-next-line no-new-func + const fn = new Function('c', `"use strict"; return (${formatter_str})(c);`); + const result = fn(sample); + preview_el.textContent = result != null ? String(result) : `null — falls back to "${sample.name}"`; + } catch (err) { + preview_el.textContent = `Error: ${err.message}`; + } +} + // --------------------------------------------------------------------------- // Render: Grids section // --------------------------------------------------------------------------- @@ -1385,6 +1539,8 @@ function parse_url() { section = 'inventory'; } else if (p0 === 'fields') { section = 'fields'; + } else if (p0 === 'templates') { + section = 'templates'; } else if (p0 === 'grids') { section = 'grids'; if (p1 === 'sources') { @@ -1464,6 +1620,7 @@ function render() { else if (section === 'inventory') render_inventory(); else if (section === 'fields') render_fields(); else if (section === 'grids') render_grids(); + else if (section === 'templates') render_templates(); } // --------------------------------------------------------------------------- diff --git a/public/index.html b/public/index.html index 855a333..3470641 100644 --- a/public/index.html +++ b/public/index.html @@ -15,6 +15,7 @@ +
diff --git a/public/lib/api.mjs b/public/lib/api.mjs index 9c3d711..badb410 100644 --- a/public/lib/api.mjs +++ b/public/lib/api.mjs @@ -38,6 +38,12 @@ export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}` export const get_source_images = () => req('GET', '/api/source-images'); export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`); +// Component templates +export const get_component_templates = () => req('GET', '/api/component-templates'); +export const create_component_template = (body) => req('POST', '/api/component-templates', body); +export const update_component_template = (id, body) => req('PUT', `/api/component-templates/${id}`, body); +export const delete_component_template = (id) => req('DELETE', `/api/component-templates/${id}`); + // Grid images export const get_grids = () => req('GET', '/api/grid-images'); export const get_grid = (id) => req('GET', `/api/grid-images/${id}`); diff --git a/public/style.css b/public/style.css index 16eb71a..d99265c 100644 --- a/public/style.css +++ b/public/style.css @@ -795,6 +795,71 @@ nav { line-height: 1.5; } +/* ===== TEMPLATES SECTION ===== */ + +.template-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.template-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; +} + +.template-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; +} + +.template-card-name { + font-weight: 600; +} + +.template-card-formatter { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-dim); + white-space: pre-wrap; + margin: 0; +} + +.code-input { + font-family: var(--font-mono); + font-size: 0.85rem; + width: 100%; + resize: vertical; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.5rem; +} + +.tmpl-preview-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + min-height: 1.5rem; +} + +.tmpl-preview-value { + font-family: var(--font-mono); + color: var(--accent, #5b9cf6); +} + +.section-empty-note { + color: var(--text-dim); + font-size: 0.9rem; + margin: 1rem 0; +} + /* ===== TAB BAR ===== */ .tab-bar { diff --git a/public/templates.html b/public/templates.html index 9610abf..563090f 100644 --- a/public/templates.html +++ b/public/templates.html @@ -82,6 +82,7 @@ + @@ -147,6 +148,54 @@ + + + + + + +