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:
@@ -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() {
|
||||
|
||||
167
public/app.mjs
167
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();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<button class="nav-btn" data-section="inventory">Inventory</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="templates">Templates</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="main"></main>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<span class="detail-inv-qty"></span>
|
||||
<span class="detail-inv-notes"></span>
|
||||
<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-danger btn-delete" title="Delete">✕</button>
|
||||
</span>
|
||||
@@ -147,6 +148,54 @@
|
||||
</tr>
|
||||
</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) => { const r = c.fields?.resistance; if (!r) return null; return `Resistor ${r}`; }"></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 ===== -->
|
||||
<template id="t-section-fields">
|
||||
<section class="section" id="section-fields">
|
||||
|
||||
33
server.mjs
33
server.mjs
@@ -15,6 +15,7 @@ import {
|
||||
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_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';
|
||||
|
||||
mkdirSync('./data/images', { recursive: true });
|
||||
@@ -423,6 +424,38 @@ app.delete('/api/grid-images/:id', (req, 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
|
||||
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
|
||||
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));
|
||||
|
||||
Reference in New Issue
Block a user