Add component name formatters and grid-link navigation

Templates section:
- Define JS formatter functions per template (e.g. resistor, capacitor)
- First non-null result from any formatter is used as display name
- Live preview in template editor against first component
- Display names applied in component list, detail view, and inventory rows

Grid navigation:
- Grid-type inventory entries in component detail view show a '⊞' button
  to navigate directly to that grid's viewer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 00:07:01 +00:00
parent 27970e74f9
commit 57c697cbfc
7 changed files with 338 additions and 5 deletions

View File

@@ -125,6 +125,28 @@ export function delete_source_image(id) {
return store.delete(`s:${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 --- // --- Grid images ---
export function list_grid_images() { export function list_grid_images() {

View File

@@ -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 grid_draft = null; // { id?, name, rows, cols, panel_rows, panel_cols, panels[], edit_grid_id? }
let current_panel_idx = null; let current_panel_idx = null;
let all_drafts = []; let all_drafts = [];
let all_templates = [];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data loading // Data loading
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function load_all() { 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_fields(),
api.get_inventory(), api.get_inventory(),
api.get_components(), api.get_components(),
api.get_grids(), api.get_grids(),
api.get_grid_drafts(), api.get_grid_drafts(),
api.get_source_images(), api.get_source_images(),
api.get_component_templates(),
]); ]);
all_fields = cf.fields; all_fields = cf.fields;
all_inventory = ci.entries; all_inventory = ci.entries;
@@ -45,6 +47,8 @@ async function load_all() {
all_grids = gr.grids; all_grids = gr.grids;
all_drafts = dr.drafts; all_drafts = dr.drafts;
all_sources = sr.sources; 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}`; 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) { function inventory_for_component(component_id) {
return all_inventory.filter(e => e.component_id === 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) { function build_component_row(comp) {
const row = clone('t-component-row'); 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 tags_el = qs(row, '.component-tags');
const field_entries = Object.entries(comp.fields ?? {}); const field_entries = Object.entries(comp.fields ?? {});
@@ -229,11 +267,11 @@ function render_detail_panel() {
const content = clone('t-detail-content'); const content = clone('t-detail-content');
// Header // Header
set_text(content, '.detail-name', comp.name); set_text(content, '.detail-name', component_display_name(comp));
set_text(content, '.detail-description', comp.description || ''); set_text(content, '.detail-description', comp.description || '');
qs(content, '.detail-edit-btn').addEventListener('click', () => open_component_dialog(comp)); qs(content, '.detail-edit-btn').addEventListener('click', () => open_component_dialog(comp));
qs(content, '.detail-delete-btn').addEventListener('click', () => confirm_delete( 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 () => { async () => {
await api.delete_component(comp.id); await api.delete_component(comp.id);
all_components = all_components.filter(c => c.id !== 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-qty', entry.quantity ? `×${entry.quantity}` : '');
set_text(el, '.detail-inv-notes', entry.notes || ''); 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 // Inventory entry images
build_image_grid( build_image_grid(
qs(el, '.inv-image-grid'), qs(el, '.inv-image-grid'),
@@ -389,7 +433,7 @@ function render_inventory_list() {
function build_inventory_row(entry) { function build_inventory_row(entry) {
const row = clone('t-inventory-row'); const row = clone('t-inventory-row');
const comp = component_by_id(entry.component_id); 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'); const pill = document.createElement('span');
pill.className = `type-pill type-${entry.location_type}`; pill.className = `type-pill type-${entry.location_type}`;
@@ -462,6 +506,116 @@ function build_field_row(fdef) {
return row; 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 // Render: Grids section
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1385,6 +1539,8 @@ function parse_url() {
section = 'inventory'; section = 'inventory';
} else if (p0 === 'fields') { } else if (p0 === 'fields') {
section = 'fields'; section = 'fields';
} else if (p0 === 'templates') {
section = 'templates';
} else if (p0 === 'grids') { } else if (p0 === 'grids') {
section = 'grids'; section = 'grids';
if (p1 === 'sources') { if (p1 === 'sources') {
@@ -1464,6 +1620,7 @@ function render() {
else if (section === 'inventory') render_inventory(); else if (section === 'inventory') render_inventory();
else if (section === 'fields') render_fields(); else if (section === 'fields') render_fields();
else if (section === 'grids') render_grids(); else if (section === 'grids') render_grids();
else if (section === 'templates') render_templates();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -15,6 +15,7 @@
<button class="nav-btn" data-section="inventory">Inventory</button> <button class="nav-btn" data-section="inventory">Inventory</button>
<button class="nav-btn" data-section="fields">Fields</button> <button class="nav-btn" data-section="fields">Fields</button>
<button class="nav-btn" data-section="grids">Grids</button> <button class="nav-btn" data-section="grids">Grids</button>
<button class="nav-btn" data-section="templates">Templates</button>
</nav> </nav>
</header> </header>
<main id="main"></main> <main id="main"></main>

View File

@@ -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 get_source_images = () => req('GET', '/api/source-images');
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`); 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 // Grid images
export const get_grids = () => req('GET', '/api/grid-images'); export const get_grids = () => req('GET', '/api/grid-images');
export const get_grid = (id) => req('GET', `/api/grid-images/${id}`); export const get_grid = (id) => req('GET', `/api/grid-images/${id}`);

View File

@@ -795,6 +795,71 @@ nav {
line-height: 1.5; 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 ===== */
.tab-bar { .tab-bar {

View File

@@ -82,6 +82,7 @@
<span class="detail-inv-qty"></span> <span class="detail-inv-qty"></span>
<span class="detail-inv-notes"></span> <span class="detail-inv-notes"></span>
<span class="row-actions"> <span class="row-actions">
<button class="btn-icon btn-goto-grid detail-inv-goto-grid" title="View in grid" hidden></button>
<button class="btn-icon btn-edit" title="Edit"></button> <button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button> <button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span> </span>
@@ -147,6 +148,54 @@
</tr> </tr>
</template> </template>
<!-- ===== TEMPLATES SECTION ===== -->
<template id="t-section-templates">
<section class="section" id="section-templates">
<div class="section-toolbar">
<span class="section-note">Formatters that compute display names from component fields</span>
<button class="btn btn-primary" id="btn-add-template">+ Add template</button>
</div>
<div id="template-list" class="template-list"></div>
</section>
</template>
<template id="t-template-card">
<div class="template-card">
<div class="template-card-header">
<span class="template-card-name"></span>
<span class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</div>
<pre class="template-card-formatter"></pre>
</div>
</template>
<template id="t-dialog-template">
<dialog id="dialog-template" class="app-dialog app-dialog-wide">
<h2 class="dialog-title"></h2>
<form method="dialog" id="form-template">
<div class="form-row">
<label for="tmpl-name">Name</label>
<input type="text" id="tmpl-name" required autocomplete="off" placeholder="e.g. Resistor">
</div>
<div class="form-row">
<label for="tmpl-formatter">Formatter <span class="label-hint">(JS arrow function, return null to skip)</span></label>
<textarea id="tmpl-formatter" rows="8" class="code-input" placeholder="(c) => {&#10; const r = c.fields?.resistance;&#10; if (!r) return null;&#10; return `Resistor ${r}`;&#10;}"></textarea>
</div>
<div class="tmpl-preview-row">
<span class="label-hint">Preview:</span>
<span id="tmpl-preview" class="tmpl-preview-value"></span>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="tmpl-cancel">Cancel</button>
<button type="submit" class="btn btn-primary" id="tmpl-save">Save</button>
</div>
</form>
</dialog>
</template>
<!-- ===== FIELDS SECTION ===== --> <!-- ===== FIELDS SECTION ===== -->
<template id="t-section-fields"> <template id="t-section-fields">
<section class="section" id="section-fields"> <section class="section" id="section-fields">

View File

@@ -15,6 +15,7 @@ import {
list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft, list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft,
list_source_images, get_source_image, add_source_image, delete_source_image, list_source_images, get_source_image, add_source_image, delete_source_image,
list_grid_images, get_grid_image, set_grid_image, delete_grid_image, list_grid_images, get_grid_image, set_grid_image, delete_grid_image,
list_component_templates, get_component_template, set_component_template, delete_component_template,
} from './lib/storage.mjs'; } from './lib/storage.mjs';
mkdirSync('./data/images', { recursive: true }); mkdirSync('./data/images', { recursive: true });
@@ -423,6 +424,38 @@ app.delete('/api/grid-images/:id', (req, res) => {
ok(res); ok(res);
}); });
// ---------------------------------------------------------------------------
// Component templates
// ---------------------------------------------------------------------------
app.get('/api/component-templates', (req, res) => {
ok(res, { templates: list_component_templates() });
});
app.post('/api/component-templates', (req, res) => {
const { name, formatter } = req.body;
if (!name) return fail(res, 'name is required');
const tmpl = { id: generate_id(), name, formatter: formatter ?? '', created_at: Date.now(), updated_at: Date.now() };
set_component_template(tmpl);
ok(res, { template: tmpl });
});
app.put('/api/component-templates/:id', (req, res) => {
const existing = get_component_template(req.params.id);
if (!existing) return fail(res, 'not found', 404);
const updated = { ...existing, updated_at: Date.now() };
if (req.body.name !== undefined) updated.name = req.body.name;
if (req.body.formatter !== undefined) updated.formatter = req.body.formatter;
set_component_template(updated);
ok(res, { template: updated });
});
app.delete('/api/component-templates/:id', (req, res) => {
if (!get_component_template(req.params.id)) return fail(res, 'not found', 404);
delete_component_template(req.params.id);
ok(res);
});
// SPA fallback — serve index.html for any non-API, non-asset path // SPA fallback — serve index.html for any non-API, non-asset path
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname; const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML)); app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));