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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
data/
|
data/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
tools/mv-sync
|
||||||
129
public/app.mjs
129
public/app.mjs
@@ -24,6 +24,7 @@ let grid_source_id = null;
|
|||||||
// Draft grid being assembled from panels
|
// Draft grid being assembled from panels
|
||||||
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 highlight_cell = null; // { row, col } — set when navigating from component detail
|
||||||
let all_drafts = [];
|
let all_drafts = [];
|
||||||
let all_templates = [];
|
let all_templates = [];
|
||||||
let all_pdfs = [];
|
let all_pdfs = [];
|
||||||
@@ -104,6 +105,22 @@ function named_fields_comp(comp) {
|
|||||||
return { ...comp, fields };
|
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) {
|
function component_display_name(comp) {
|
||||||
if (!compiled_formatters.length) return comp.name;
|
if (!compiled_formatters.length) return comp.name;
|
||||||
const c = named_fields_comp(comp);
|
const c = named_fields_comp(comp);
|
||||||
@@ -134,16 +151,14 @@ function field_by_id(id) {
|
|||||||
|
|
||||||
function matches_search(component, query) {
|
function matches_search(component, query) {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const q = query.toLowerCase();
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
if (component.name.toLowerCase().includes(q)) return true;
|
const haystack = [
|
||||||
if (component.description.toLowerCase().includes(q)) return true;
|
component.name,
|
||||||
for (const val of Object.values(component.fields ?? {})) {
|
component.description,
|
||||||
if (String(val).toLowerCase().includes(q)) return true;
|
...Object.values(component.fields ?? {}).map(String),
|
||||||
}
|
...inventory_for_component(component.id).map(e => e.location_ref),
|
||||||
for (const entry of inventory_for_component(component.id)) {
|
].join('\n').toLowerCase();
|
||||||
if (entry.location_ref.toLowerCase().includes(q)) return true;
|
return words.every(w => haystack.includes(w));
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matches_inventory_search(entry, query, type_filter) {
|
function matches_inventory_search(entry, query, type_filter) {
|
||||||
@@ -205,6 +220,30 @@ function render_components() {
|
|||||||
main.replaceChildren(frag);
|
main.replaceChildren(frag);
|
||||||
section_el = document.getElementById('section-components');
|
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) => {
|
qs(section_el, '#component-search').addEventListener('input', (e) => {
|
||||||
component_search = e.target.value;
|
component_search = e.target.value;
|
||||||
render_component_list();
|
render_component_list();
|
||||||
@@ -217,6 +256,7 @@ function render_components() {
|
|||||||
all_components.push(result.component);
|
all_components.push(result.component);
|
||||||
all_components.sort((a, b) => a.name.localeCompare(b.name));
|
all_components.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
selected_component_id = result.component.id;
|
selected_component_id = result.component.id;
|
||||||
|
history.replaceState(null, '', `/components/${result.component.id}`);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
render_component_list();
|
render_component_list();
|
||||||
render_detail_panel();
|
render_detail_panel();
|
||||||
@@ -254,7 +294,7 @@ function build_component_row(comp) {
|
|||||||
const tag = clone('t-field-tag');
|
const tag = clone('t-field-tag');
|
||||||
const def = field_by_id(fid);
|
const def = field_by_id(fid);
|
||||||
set_text(tag, '.tag-name', def ? def.name : 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;
|
return tag;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -267,6 +307,7 @@ function build_component_row(comp) {
|
|||||||
document.querySelectorAll('.component-row.selected').forEach(r => r.classList.remove('selected'));
|
document.querySelectorAll('.component-row.selected').forEach(r => r.classList.remove('selected'));
|
||||||
row.classList.add('selected');
|
row.classList.add('selected');
|
||||||
selected_component_id = comp.id;
|
selected_component_id = comp.id;
|
||||||
|
history.replaceState(null, '', `/components/${comp.id}`);
|
||||||
render_detail_panel();
|
render_detail_panel();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,6 +343,7 @@ function render_detail_panel() {
|
|||||||
all_components.push(result.component);
|
all_components.push(result.component);
|
||||||
all_components.sort((a, b) => a.name.localeCompare(b.name));
|
all_components.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
selected_component_id = result.component.id;
|
selected_component_id = result.component.id;
|
||||||
|
history.replaceState(null, '', `/components/${result.component.id}`);
|
||||||
render();
|
render();
|
||||||
open_component_dialog(result.component);
|
open_component_dialog(result.component);
|
||||||
});
|
});
|
||||||
@@ -311,13 +353,18 @@ function render_detail_panel() {
|
|||||||
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);
|
||||||
selected_component_id = null;
|
selected_component_id = null;
|
||||||
|
history.replaceState(null, '', '/components');
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
const fields_el = qs(content, '.detail-fields-list');
|
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) {
|
if (field_entries.length === 0) {
|
||||||
fields_el.textContent = 'No fields set.';
|
fields_el.textContent = 'No fields set.';
|
||||||
fields_el.classList.add('detail-empty-note');
|
fields_el.classList.add('detail-empty-note');
|
||||||
@@ -326,18 +373,7 @@ function render_detail_panel() {
|
|||||||
const row = clone('t-detail-field-row');
|
const row = clone('t-detail-field-row');
|
||||||
const def = field_by_id(fid);
|
const def = field_by_id(fid);
|
||||||
set_text(row, '.detail-field-name', def ? def.name : fid);
|
set_text(row, '.detail-field-name', def ? def.name : fid);
|
||||||
const display = def?.unit ? `${val} ${def.unit}` : String(val);
|
render_field_value(qs(row, '.detail-field-value'), val, def);
|
||||||
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;
|
|
||||||
}
|
|
||||||
return row;
|
return row;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -461,16 +497,15 @@ function build_detail_inv_entry(entry) {
|
|||||||
const ref_el = qs(el, '.detail-inv-ref');
|
const ref_el = qs(el, '.detail-inv-ref');
|
||||||
ref_el.textContent = grid_cell_label(entry);
|
ref_el.textContent = grid_cell_label(entry);
|
||||||
ref_el.classList.add('detail-inv-ref-link');
|
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
|
// Show the grid cell image as a read-only thumbnail
|
||||||
const grid = all_grids.find(g => g.id === entry.grid_id);
|
const grid = all_grids.find(g => g.id === entry.grid_id);
|
||||||
const cell_filename = grid?.cells?.[entry.grid_row]?.[entry.grid_col];
|
const cell_filename = grid?.cells?.[entry.grid_row]?.[entry.grid_col];
|
||||||
if (cell_filename) {
|
if (cell_filename) {
|
||||||
const thumb = document.createElement('a');
|
const thumb = document.createElement('div');
|
||||||
thumb.className = 'thumb-link cell-thumb-link';
|
thumb.className = 'cell-thumb-preview';
|
||||||
thumb.href = '#';
|
thumb.addEventListener('click', () => open_lightbox(`/img/${cell_filename}`));
|
||||||
thumb.addEventListener('click', (e) => { e.preventDefault(); open_lightbox(`/img/${cell_filename}`); });
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.className = 'thumb-img';
|
img.className = 'thumb-img';
|
||||||
img.src = `/img/${cell_filename}`;
|
img.src = `/img/${cell_filename}`;
|
||||||
@@ -566,8 +601,7 @@ function build_inventory_row(entry) {
|
|||||||
if (comp) {
|
if (comp) {
|
||||||
name_el.classList.add('inv-component-link');
|
name_el.classList.add('inv-component-link');
|
||||||
name_el.addEventListener('click', () => {
|
name_el.addEventListener('click', () => {
|
||||||
selected_component_id = comp.id;
|
navigate(`/components/${comp.id}`);
|
||||||
navigate('/components');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1330,10 +1364,17 @@ function render_grid_viewer() {
|
|||||||
index_span.style.marginLeft = 'auto';
|
index_span.style.marginLeft = 'auto';
|
||||||
label_el.appendChild(index_span);
|
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));
|
cell.addEventListener('click', (e) => open_cell_inventory(grid, row, col, e));
|
||||||
return cell;
|
return cell;
|
||||||
});
|
});
|
||||||
cells_el.replaceChildren(...all_cells);
|
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) {
|
function open_cell_inventory(grid, row, col, e) {
|
||||||
@@ -1366,8 +1407,9 @@ function open_cell_inventory(grid, row, col, e) {
|
|||||||
} else {
|
} else {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
const comp = component_by_id(entry.component_id);
|
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';
|
item.className = 'cell-inv-item cell-inv-item-link';
|
||||||
|
if (comp) { item.href = `/components/${comp.id}`; }
|
||||||
const name_span = document.createElement('span');
|
const name_span = document.createElement('span');
|
||||||
name_span.textContent = comp ? component_display_name(comp) : '?';
|
name_span.textContent = comp ? component_display_name(comp) : '?';
|
||||||
const qty_span = document.createElement('span');
|
const qty_span = document.createElement('span');
|
||||||
@@ -1375,10 +1417,10 @@ function open_cell_inventory(grid, row, col, e) {
|
|||||||
qty_span.textContent = entry.quantity || '';
|
qty_span.textContent = entry.quantity || '';
|
||||||
item.append(name_span, qty_span);
|
item.append(name_span, qty_span);
|
||||||
if (comp) {
|
if (comp) {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
selected_component_id = comp.id;
|
navigate(`/components/${comp.id}`);
|
||||||
navigate('/components');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
list_el.appendChild(item);
|
list_el.appendChild(item);
|
||||||
@@ -1533,7 +1575,12 @@ function open_component_dialog(comp = null) {
|
|||||||
const active_fields = new Map(Object.entries(comp?.fields ?? {}));
|
const active_fields = new Map(Object.entries(comp?.fields ?? {}));
|
||||||
|
|
||||||
function rebuild_field_rows() {
|
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 def = field_by_id(fid);
|
||||||
|
|
||||||
const row_el = document.createElement('div');
|
const row_el = document.createElement('div');
|
||||||
@@ -1545,7 +1592,7 @@ function open_component_dialog(comp = null) {
|
|||||||
if (def?.unit) {
|
if (def?.unit) {
|
||||||
const unit_span = document.createElement('span');
|
const unit_span = document.createElement('span');
|
||||||
unit_span.className = 'c-field-unit-hint';
|
unit_span.className = 'c-field-unit-hint';
|
||||||
unit_span.textContent = ` [${def.unit}]`;
|
unit_span.textContent = `[${def.unit}]`;
|
||||||
label_el.appendChild(unit_span);
|
label_el.appendChild(unit_span);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1636,6 +1683,7 @@ function open_component_dialog(comp = null) {
|
|||||||
all_components.push(result.component);
|
all_components.push(result.component);
|
||||||
all_components.sort((a, b) => a.name.localeCompare(b.name));
|
all_components.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
selected_component_id = result.component.id;
|
selected_component_id = result.component.id;
|
||||||
|
history.replaceState(null, '', `/components/${result.component.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1858,11 +1906,18 @@ function parse_url() {
|
|||||||
current_panel_idx = null;
|
current_panel_idx = null;
|
||||||
grid_draft = null;
|
grid_draft = null;
|
||||||
grid_source_id = 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;
|
const [p0, p1, p2, p3, p4] = parts;
|
||||||
|
|
||||||
if (!p0 || p0 === 'components') {
|
if (!p0 || p0 === 'components') {
|
||||||
section = 'components';
|
section = 'components';
|
||||||
|
if (p1) selected_component_id = p1;
|
||||||
} else if (p0 === 'inventory') {
|
} else if (p0 === 'inventory') {
|
||||||
section = 'inventory';
|
section = 'inventory';
|
||||||
} else if (p0 === 'fields') {
|
} else if (p0 === 'fields') {
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
--font-mono: 'Fira Code', 'Cascadia Code', monospace;
|
--font-mono: 'Fira Code', 'Cascadia Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -185,10 +193,23 @@ nav {
|
|||||||
|
|
||||||
.split-layout {
|
.split-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
|
||||||
align-items: flex-start;
|
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 {
|
.list-pane {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -199,6 +220,7 @@ nav {
|
|||||||
top: calc(3rem + 1.5rem); /* header + main padding */
|
top: calc(3rem + 1.5rem); /* header + main padding */
|
||||||
max-height: calc(100vh - 3rem - 3rem);
|
max-height: calc(100vh - 3rem - 3rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
margin-right: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-add-input {
|
.quick-add-input {
|
||||||
@@ -236,6 +258,7 @@ nav {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
margin-left: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== DETAIL PANEL ===== */
|
/* ===== DETAIL PANEL ===== */
|
||||||
@@ -300,7 +323,7 @@ nav {
|
|||||||
|
|
||||||
.detail-field-row {
|
.detail-field-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 10rem 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
@@ -310,11 +333,16 @@ nav {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-field-value {
|
.detail-field-value {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-empty-note {
|
.detail-empty-note {
|
||||||
@@ -351,7 +379,6 @@ nav {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
display: block;
|
display: block;
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.1s;
|
transition: border-color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,15 +477,15 @@ nav {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-thumb-link {
|
.cell-thumb-preview {
|
||||||
display: block;
|
display: block;
|
||||||
border: 2px solid var(--accent, #5b9cf6);
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-thumb-link .thumb-img {
|
.cell-thumb-preview .thumb-img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 128px;
|
width: 128px;
|
||||||
height: 128px;
|
height: 128px;
|
||||||
@@ -854,14 +881,18 @@ nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Field input rows inside component dialog */
|
/* Field input rows inside component dialog */
|
||||||
.c-field-input-row {
|
#c-field-rows {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr auto;
|
grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto;
|
||||||
gap: 0.5rem;
|
gap: 0.35rem 0.6rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c-field-input-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.c-field-input-label {
|
.c-field-input-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -869,6 +900,7 @@ nav {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-field-unit-hint {
|
.c-field-unit-hint {
|
||||||
@@ -1452,6 +1484,11 @@ nav {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-cell.highlighted .grid-cell-img-wrap {
|
||||||
|
outline: 3px solid var(--accent, #5b9cf6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-cell-img-wrap {
|
.grid-cell-img-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1620,6 +1657,8 @@ nav {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.1rem 0.25rem;
|
padding: 0.1rem 0.25rem;
|
||||||
margin: 0 -0.25rem;
|
margin: 0 -0.25rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-inv-item-link:hover {
|
.cell-inv-item-link:hover {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
<template id="t-section-components">
|
<template id="t-section-components">
|
||||||
<section class="section" id="section-components">
|
<section class="section" id="section-components">
|
||||||
<div class="split-layout">
|
<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="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">
|
<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 id="component-list" class="component-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="split-resizer" id="split-resizer"></div>
|
||||||
<div class="detail-pane" id="detail-pane"></div>
|
<div class="detail-pane" id="detail-pane"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -383,7 +384,7 @@
|
|||||||
|
|
||||||
<!-- ===== DIALOG: COMPONENT ===== -->
|
<!-- ===== DIALOG: COMPONENT ===== -->
|
||||||
<template id="t-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>
|
<h2 class="dialog-title"></h2>
|
||||||
<form method="dialog" id="form-component">
|
<form method="dialog" id="form-component">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
40
server.mjs
40
server.mjs
@@ -63,6 +63,31 @@ function remove_image_file(img_id) {
|
|||||||
try { unlinkSync(join('./data/images', img_id)); } catch {}
|
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) {
|
function generate_pdf_thumb(pdf_path, thumb_prefix) {
|
||||||
// Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable.
|
// Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable.
|
||||||
try {
|
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');
|
return fail(res, 'a file with that name already exists');
|
||||||
}
|
}
|
||||||
const id = generate_id();
|
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_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 = {
|
const pdf = {
|
||||||
id,
|
id,
|
||||||
filename: req.file.filename,
|
filename,
|
||||||
display_name,
|
display_name,
|
||||||
original_name: req.file.originalname,
|
original_name: req.file.originalname,
|
||||||
size: req.file.size,
|
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)) {
|
if (list_pdfs().some(p => p.display_name === display_name && p.id !== pdf.id)) {
|
||||||
return fail(res, 'a file with that name already exists');
|
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);
|
set_pdf(updated);
|
||||||
ok(res, { pdf: updated });
|
ok(res, { pdf: updated });
|
||||||
});
|
});
|
||||||
|
|||||||
10
tools/Makefile
Normal file
10
tools/Makefile
Normal 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
42
tools/mv-sync.c
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user