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:
2026-03-22 02:49:11 +00:00
parent 7ef5bb5381
commit 58c93f2bd0
7 changed files with 233 additions and 51 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/
data/
package-lock.json
tools/mv-sync

View File

@@ -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');
@@ -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') {

View File

@@ -32,6 +32,14 @@
--font-mono: 'Fira Code', 'Cascadia Code', monospace;
}
a {
color: var(--accent);
}
a:hover {
color: var(--accent-hover);
}
body {
background: var(--bg);
color: var(--text);
@@ -185,10 +193,23 @@ nav {
.split-layout {
display: flex;
gap: 1.25rem;
align-items: flex-start;
}
.split-resizer {
width: 5px;
flex-shrink: 0;
align-self: stretch;
cursor: col-resize;
background: transparent;
transition: background 0.15s;
}
.split-resizer:hover,
.split-resizer.dragging {
background: var(--accent);
}
.list-pane {
width: 300px;
flex-shrink: 0;
@@ -199,6 +220,7 @@ nav {
top: calc(3rem + 1.5rem); /* header + main padding */
max-height: calc(100vh - 3rem - 3rem);
overflow-y: auto;
margin-right: 0.625rem;
}
.quick-add-input {
@@ -236,6 +258,7 @@ nav {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
margin-left: 0.625rem;
}
/* ===== DETAIL PANEL ===== */
@@ -300,7 +323,7 @@ nav {
.detail-field-row {
display: grid;
grid-template-columns: 10rem 1fr;
grid-template-columns: auto 1fr;
gap: 0.5rem;
font-size: 0.9rem;
padding: 0.2rem 0;
@@ -310,11 +333,16 @@ nav {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.85rem;
overflow-wrap: break-word;
min-width: 0;
max-width: 14rem;
}
.detail-field-value {
font-family: var(--font-mono);
font-size: 0.85rem;
min-width: 0;
overflow-wrap: break-word;
}
.detail-empty-note {
@@ -351,7 +379,6 @@ nav {
border-radius: 4px;
border: 1px solid var(--border);
display: block;
cursor: pointer;
transition: border-color 0.1s;
}
@@ -450,15 +477,15 @@ nav {
gap: 0.4rem;
}
.cell-thumb-link {
.cell-thumb-preview {
display: block;
border: 2px solid var(--accent, #5b9cf6);
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
cursor: zoom-in;
}
.cell-thumb-link .thumb-img {
.cell-thumb-preview .thumb-img {
display: block;
width: 128px;
height: 128px;
@@ -854,14 +881,18 @@ nav {
}
/* Field input rows inside component dialog */
.c-field-input-row {
#c-field-rows {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.5rem;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto;
gap: 0.35rem 0.6rem;
align-items: center;
margin-bottom: 0.5rem;
}
.c-field-input-row {
display: contents;
}
.c-field-input-label {
font-size: 0.85rem;
color: var(--text-dim);
@@ -869,6 +900,7 @@ nav {
display: flex;
align-items: baseline;
gap: 0.3rem;
white-space: nowrap;
}
.c-field-unit-hint {
@@ -1452,6 +1484,11 @@ nav {
gap: 2px;
}
.grid-cell.highlighted .grid-cell-img-wrap {
outline: 3px solid var(--accent, #5b9cf6);
outline-offset: 2px;
}
.grid-cell-img-wrap {
position: relative;
width: 100%;
@@ -1620,6 +1657,8 @@ nav {
border-radius: 3px;
padding: 0.1rem 0.25rem;
margin: 0 -0.25rem;
color: inherit;
text-decoration: none;
}
.cell-inv-item-link:hover {

View File

@@ -2,11 +2,12 @@
<template id="t-section-components">
<section class="section" id="section-components">
<div class="split-layout">
<div class="list-pane">
<div class="list-pane" id="list-pane">
<input type="search" id="component-search" class="search-input" placeholder="Search…">
<input type="text" id="quick-add" class="quick-add-input" placeholder="New component… ↵" autocomplete="off" spellcheck="false">
<div id="component-list" class="component-list"></div>
</div>
<div class="split-resizer" id="split-resizer"></div>
<div class="detail-pane" id="detail-pane"></div>
</div>
</section>
@@ -383,7 +384,7 @@
<!-- ===== DIALOG: COMPONENT ===== -->
<template id="t-dialog-component">
<dialog id="dialog-component" class="app-dialog">
<dialog id="dialog-component" class="app-dialog app-dialog-wide">
<h2 class="dialog-title"></h2>
<form method="dialog" id="form-component">
<div class="form-row">

View File

@@ -63,6 +63,31 @@ function remove_image_file(img_id) {
try { unlinkSync(join('./data/images', img_id)); } catch {}
}
const MV_SYNC = new URL('../tools/mv-sync', import.meta.url).pathname;
// Atomically rename src -> dst, failing if dst already exists.
// Uses renameat2(RENAME_NOREPLACE) via tools/mv-sync.
// Throws on unexpected errors; returns false if dst exists.
function rename_no_replace(src, dst) {
try {
execFileSync(MV_SYNC, [src, dst]);
return true;
} catch (e) {
if (e.status === 1 && e.stderr?.toString().includes('File exists')) return false;
throw e;
}
}
function sanitize_pdf_filename(display_name) {
const base = display_name
.replace(/\.pdf$/i, '')
.replace(/[^a-zA-Z0-9._\- ]/g, '')
.trim()
.replace(/\s+/g, '_')
|| 'document';
return base + '.pdf';
}
function generate_pdf_thumb(pdf_path, thumb_prefix) {
// Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable.
try {
@@ -507,11 +532,15 @@ app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => {
return fail(res, 'a file with that name already exists');
}
const id = generate_id();
const filename = sanitize_pdf_filename(display_name);
const temp_path = join('./data/pdfs', req.file.filename);
const final_path = join('./data/pdfs', filename);
if (!rename_no_replace(temp_path, final_path)) return fail(res, 'a file with that name already exists on disk');
const thumb_prefix = join('./data/pdfs', id + '-thumb');
const thumb_file = generate_pdf_thumb(join('./data/pdfs', req.file.filename), thumb_prefix);
const thumb_file = generate_pdf_thumb(final_path, thumb_prefix);
const pdf = {
id,
filename: req.file.filename,
filename,
display_name,
original_name: req.file.originalname,
size: req.file.size,
@@ -530,7 +559,12 @@ app.put('/api/pdfs/:id', (req, res) => {
if (list_pdfs().some(p => p.display_name === display_name && p.id !== pdf.id)) {
return fail(res, 'a file with that name already exists');
}
const updated = { ...pdf, display_name };
const new_filename = sanitize_pdf_filename(display_name);
if (new_filename !== pdf.filename) {
const new_path = join('./data/pdfs', new_filename);
if (!rename_no_replace(join('./data/pdfs', pdf.filename), new_path)) return fail(res, 'a file with that name already exists on disk');
}
const updated = { ...pdf, display_name, filename: new_filename };
set_pdf(updated);
ok(res, { pdf: updated });
});

10
tools/Makefile Normal file
View File

@@ -0,0 +1,10 @@
CC = gcc
CFLAGS = -O2 -Wall -Wextra
all: mv-sync
mv-sync: mv-sync.c
$(CC) $(CFLAGS) -o mv-sync mv-sync.c
clean:
rm -f mv-sync

42
tools/mv-sync.c Normal file
View File

@@ -0,0 +1,42 @@
/*
* mv-sync: atomic rename that fails if the destination already exists.
*
* Uses renameat2(2) with RENAME_NOREPLACE (Linux 3.15+).
*
* Exit codes:
* 0 success
* 1 rename failed (reason on stderr)
* 2 wrong number of arguments
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#ifndef RENAME_NOREPLACE
#define RENAME_NOREPLACE (1 << 0)
#endif
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "usage: mv-sync <src> <dst>\n");
return 2;
}
long ret = syscall(SYS_renameat2,
AT_FDCWD, argv[1],
AT_FDCWD, argv[2],
RENAME_NOREPLACE);
if (ret != 0) {
fprintf(stderr, "mv-sync: rename \"%s\" -> \"%s\": %s\n",
argv[1], argv[2], strerror(errno));
return 1;
}
return 0;
}