Many UX and correctness improvements
- Components URL reflects selected component (/components/:id), survives refresh - Word-split search: "0603 res" matches "Resistor 0603" - Left pane resizable with localStorage persistence - Field values rendered via central render_field_value() (units, URLs, extensible) - Fields sorted alphabetically in both detail view and edit dialog - Edit component dialog widened; field rows use shared grid columns (table-like) - No space between value and unit (supports prefix suffixes like k, M, µ) - Grid viewer highlights and scrolls to cell when navigating from component detail - Cell inventory overlay items are <a> tags — middle-click opens in new tab - PDF files stored with sanitized human-readable filename, not random ID - PDF rename also renames file on disk - Atomic rename via renameat2(RENAME_NOREPLACE) through tools/mv-sync - Fix .cell-thumb-link → .cell-thumb-preview (div, not anchor), cursor: zoom-in - Fix field name overflow in detail view (auto column width, overflow-wrap) - Fix link color: use --accent instead of browser default dark blue Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
129
public/app.mjs
129
public/app.mjs
@@ -24,6 +24,7 @@ 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 = [];
|
||||
@@ -104,6 +105,22 @@ function named_fields_comp(comp) {
|
||||
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);
|
||||
@@ -134,16 +151,14 @@ function field_by_id(id) {
|
||||
|
||||
function matches_search(component, query) {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
if (component.name.toLowerCase().includes(q)) return true;
|
||||
if (component.description.toLowerCase().includes(q)) return true;
|
||||
for (const val of Object.values(component.fields ?? {})) {
|
||||
if (String(val).toLowerCase().includes(q)) return true;
|
||||
}
|
||||
for (const entry of inventory_for_component(component.id)) {
|
||||
if (entry.location_ref.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
return false;
|
||||
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) {
|
||||
@@ -205,6 +220,30 @@ function render_components() {
|
||||
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();
|
||||
@@ -217,6 +256,7 @@ function render_components() {
|
||||
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();
|
||||
@@ -254,7 +294,7 @@ function build_component_row(comp) {
|
||||
const tag = clone('t-field-tag');
|
||||
const def = field_by_id(fid);
|
||||
set_text(tag, '.tag-name', def ? def.name : fid);
|
||||
set_text(tag, '.tag-value', def?.unit ? `${val} ${def.unit}` : String(val));
|
||||
render_field_value(qs(tag, '.tag-value'), val, def);
|
||||
return tag;
|
||||
}));
|
||||
}
|
||||
@@ -267,6 +307,7 @@ function build_component_row(comp) {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -302,6 +343,7 @@ function render_detail_panel() {
|
||||
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);
|
||||
});
|
||||
@@ -311,13 +353,18 @@ function render_detail_panel() {
|
||||
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 ?? {});
|
||||
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');
|
||||
@@ -326,18 +373,7 @@ function render_detail_panel() {
|
||||
const row = clone('t-detail-field-row');
|
||||
const def = field_by_id(fid);
|
||||
set_text(row, '.detail-field-name', def ? def.name : fid);
|
||||
const display = def?.unit ? `${val} ${def.unit}` : String(val);
|
||||
const value_el = qs(row, '.detail-field-value');
|
||||
if (/^https?:\/\//i.test(val)) {
|
||||
const a = document.createElement('a');
|
||||
a.href = val;
|
||||
a.textContent = display;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
value_el.replaceChildren(a);
|
||||
} else {
|
||||
value_el.textContent = display;
|
||||
}
|
||||
render_field_value(qs(row, '.detail-field-value'), val, def);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
@@ -461,16 +497,15 @@ function build_detail_inv_entry(entry) {
|
||||
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}`));
|
||||
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('a');
|
||||
thumb.className = 'thumb-link cell-thumb-link';
|
||||
thumb.href = '#';
|
||||
thumb.addEventListener('click', (e) => { e.preventDefault(); open_lightbox(`/img/${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}`;
|
||||
@@ -566,8 +601,7 @@ function build_inventory_row(entry) {
|
||||
if (comp) {
|
||||
name_el.classList.add('inv-component-link');
|
||||
name_el.addEventListener('click', () => {
|
||||
selected_component_id = comp.id;
|
||||
navigate('/components');
|
||||
navigate(`/components/${comp.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1330,10 +1364,17 @@ function render_grid_viewer() {
|
||||
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) {
|
||||
@@ -1366,8 +1407,9 @@ function open_cell_inventory(grid, row, col, e) {
|
||||
} else {
|
||||
entries.forEach(entry => {
|
||||
const comp = component_by_id(entry.component_id);
|
||||
const item = document.createElement('div');
|
||||
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');
|
||||
@@ -1375,10 +1417,10 @@ function open_cell_inventory(grid, row, col, e) {
|
||||
qty_span.textContent = entry.quantity || '';
|
||||
item.append(name_span, qty_span);
|
||||
if (comp) {
|
||||
item.addEventListener('click', () => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
overlay.remove();
|
||||
selected_component_id = comp.id;
|
||||
navigate('/components');
|
||||
navigate(`/components/${comp.id}`);
|
||||
});
|
||||
}
|
||||
list_el.appendChild(item);
|
||||
@@ -1533,7 +1575,12 @@ function open_component_dialog(comp = null) {
|
||||
const active_fields = new Map(Object.entries(comp?.fields ?? {}));
|
||||
|
||||
function rebuild_field_rows() {
|
||||
field_rows_el.replaceChildren(...[...active_fields.entries()].map(([fid, val]) => {
|
||||
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');
|
||||
@@ -1545,7 +1592,7 @@ function open_component_dialog(comp = null) {
|
||||
if (def?.unit) {
|
||||
const unit_span = document.createElement('span');
|
||||
unit_span.className = 'c-field-unit-hint';
|
||||
unit_span.textContent = ` [${def.unit}]`;
|
||||
unit_span.textContent = `[${def.unit}]`;
|
||||
label_el.appendChild(unit_span);
|
||||
}
|
||||
|
||||
@@ -1636,6 +1683,7 @@ function open_component_dialog(comp = null) {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1858,11 +1906,18 @@ function parse_url() {
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user