diff --git a/.gitignore b/.gitignore index a8ac7b6..b26711e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ data/ package-lock.json +tools/mv-sync \ No newline at end of file diff --git a/public/app.mjs b/public/app.mjs index c2d6af8..3e94ee3 100644 --- a/public/app.mjs +++ b/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') { diff --git a/public/style.css b/public/style.css index 6123a8b..d326bf8 100644 --- a/public/style.css +++ b/public/style.css @@ -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 { diff --git a/public/templates.html b/public/templates.html index fd7cad1..be8ee29 100644 --- a/public/templates.html +++ b/public/templates.html @@ -2,11 +2,12 @@