From 090f6f3154e47488bfa348a6eeadbb1fff660c58 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Fri, 3 Apr 2026 03:04:04 +0000 Subject: [PATCH] Add bin types: reusable named dimension presets for bins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bin types store a name, physical W×H in mm, and optional description. When editing a bin, a type can be selected from a dropdown; this pre-fills and locks the dimension inputs. Custom dimensions remain available when no type is selected. - lib/storage.mjs: bin type CRUD with bt: prefix - server.mjs: /api/bin-types CRUD routes; type_id accepted on bin create/update routes; DELETE protected if any bin references the type; type dims copied onto bin when type_id is set - public/lib/api.mjs: bin type wrappers; rename_bin → update_bin (accepts any fields) - public/templates.html: Types tab in bins section; t-bin-type-row; t-dialog-bin-type; type selector in bin editor dialog - public/app.mjs: all_bin_types state loaded at startup; render_bin_types_list(); open_bin_type_dialog(); type selector in open_bin_editor(); /bins/types routing - public/style.css: bin types list styles Co-Authored-By: Claude Sonnet 4.6 --- lib/storage.mjs | 22 ++++++++ public/app.mjs | 126 +++++++++++++++++++++++++++++++++++++----- public/lib/api.mjs | 8 ++- public/style.css | 40 ++++++++++++++ public/templates.html | 51 +++++++++++++++++ server.mjs | 106 ++++++++++++++++++++++++++++------- 6 files changed, 318 insertions(+), 35 deletions(-) diff --git a/lib/storage.mjs b/lib/storage.mjs index 1bb6849..5c949fc 100644 --- a/lib/storage.mjs +++ b/lib/storage.mjs @@ -174,6 +174,28 @@ export function find_pdf_references(pdf_id) { return list_components().filter(c => c.file_ids?.includes(pdf_id)); } +// --- Bin types --- + +export function list_bin_types() { + const result = []; + for (const [key] of store.data.entries()) { + if (key.startsWith('bt:')) result.push(store.get(key)); + } + return result.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function get_bin_type(id) { + return store.get(`bt:${id}`) ?? null; +} + +export function set_bin_type(bt) { + store.set(`bt:${bt.id}`, bt); +} + +export function delete_bin_type(id) { + return store.delete(`bt:${id}`); +} + // --- Bins --- export function list_bins() { diff --git a/public/app.mjs b/public/app.mjs index 8ea418c..19e7471 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -29,16 +29,18 @@ let all_drafts = []; let all_templates = []; let all_pdfs = []; let all_bins = []; -let bin_tab = 'bins'; // 'bins' | 'sources' +let all_bin_types = []; +let bin_tab = 'bins'; // 'bins' | 'sources' | 'types' let bin_editor_instance = null; let bin_editor_bin_id = null; +let bin_type_dialog_callback = null; // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- async function load_all() { - const [cf, ci, cmp, gr, dr, sr, ct, pd, bn] = await Promise.all([ + const [cf, ci, cmp, gr, dr, sr, ct, pd, bn, bt] = await Promise.all([ api.get_fields(), api.get_inventory(), api.get_components(), @@ -48,6 +50,7 @@ async function load_all() { api.get_component_templates(), api.get_pdfs(), api.get_bins(), + api.get_bin_types(), ]); all_fields = cf.fields; all_inventory = ci.entries; @@ -58,6 +61,7 @@ async function load_all() { all_templates = ct.templates; all_pdfs = pd.pdfs; all_bins = bn.bins; + all_bin_types = bt.bin_types; compile_templates(); } @@ -2009,6 +2013,12 @@ function render_bins() { history.replaceState(null, '', '/bins/sources'); update_bin_tabs(sec); }); + qs(sec, '#btn-tab-bin-types').addEventListener('click', () => { + bin_tab = 'types'; + history.replaceState(null, '', '/bins/types'); + update_bin_tabs(sec); + }); + qs(sec, '#btn-add-bin-type').addEventListener('click', () => open_bin_type_dialog()); qs(sec, '#bin-source-upload-input').addEventListener('change', async (e) => { const files = [...e.target.files]; if (!files.length) return; @@ -2033,14 +2043,70 @@ function render_bins() { update_bin_tabs(sec); render_bin_list(); render_bin_source_list(); + render_bin_types_list(); } function update_bin_tabs(sec) { qs(sec, '#btn-tab-bins').classList.toggle('active', bin_tab === 'bins'); qs(sec, '#btn-tab-bin-sources').classList.toggle('active', bin_tab === 'sources'); + qs(sec, '#btn-tab-bin-types').classList.toggle('active', bin_tab === 'types'); qs(sec, '#btn-upload-bin-sources').hidden = (bin_tab !== 'sources'); + qs(sec, '#btn-add-bin-type').hidden = (bin_tab !== 'types'); qs(sec, '#tab-bins-content').hidden = (bin_tab !== 'bins'); qs(sec, '#tab-bin-sources-content').hidden = (bin_tab !== 'sources'); + qs(sec, '#tab-bin-types-content').hidden = (bin_tab !== 'types'); +} + +function render_bin_types_list() { + const list_el = document.getElementById('bin-types-list'); + if (!list_el) return; + if (all_bin_types.length === 0) { + const el = clone('t-empty-block'); + el.textContent = 'No bin types yet. Click "+ Add type" to define one.'; + list_el.replaceChildren(el); + return; + } + list_el.replaceChildren(...all_bin_types.map(bt => { + const row = clone('t-bin-type-row'); + qs(row, '.bin-type-name').textContent = bt.name; + qs(row, '.bin-type-dims').textContent = `${bt.phys_w} × ${bt.phys_h} mm`; + qs(row, '.bin-type-desc').textContent = bt.description || ''; + qs(row, '.btn-edit').addEventListener('click', () => open_bin_type_dialog(bt)); + qs(row, '.btn-delete').addEventListener('click', () => { + confirm_delete(`Delete bin type "${bt.name}"?`, async () => { + await api.delete_bin_type(bt.id); + all_bin_types = all_bin_types.filter(t => t.id !== bt.id); + render_bin_types_list(); + }); + }); + return row; + })); +} + +function open_bin_type_dialog(bt = null) { + const dlg = document.getElementById('dialog-bin-type'); + dlg.querySelector('.dialog-title').textContent = bt ? 'Edit bin type' : 'Add bin type'; + document.getElementById('bt-name').value = bt?.name ?? ''; + document.getElementById('bt-width').value = bt?.phys_w ?? ''; + document.getElementById('bt-height').value = bt?.phys_h ?? ''; + document.getElementById('bt-description').value = bt?.description ?? ''; + bin_type_dialog_callback = async () => { + const name = document.getElementById('bt-name').value.trim(); + const phys_w = parseFloat(document.getElementById('bt-width').value); + const phys_h = parseFloat(document.getElementById('bt-height').value); + const description = document.getElementById('bt-description').value.trim(); + if (!name) { alert('Name is required.'); return; } + if (!(phys_w > 0) || !(phys_h > 0)) { alert('Dimensions must be positive numbers.'); return; } + if (bt) { + const r = await api.update_bin_type(bt.id, { name, phys_w, phys_h, description }); + all_bin_types = all_bin_types.map(t => t.id === bt.id ? r.bin_type : t); + } else { + const r = await api.create_bin_type({ name, phys_w, phys_h, description }); + all_bin_types = [...all_bin_types, r.bin_type].sort((a, b) => a.name.localeCompare(b.name)); + } + render_bin_types_list(); + }; + dlg.showModal(); } function render_bin_list() { @@ -2106,8 +2172,30 @@ function open_bin_editor(bin) { bin_editor_bin_id = bin.id; document.getElementById('bin-editor-name').value = bin.name; - document.getElementById('bin-editor-width').value = bin.phys_w ?? ''; - document.getElementById('bin-editor-height').value = bin.phys_h ?? ''; + + // Populate type selector + const type_sel = document.getElementById('bin-editor-type'); + type_sel.replaceChildren(new Option('— Custom —', '')); + for (const bt of all_bin_types) { + type_sel.appendChild(new Option(`${bt.name} (${bt.phys_w}×${bt.phys_h}mm)`, bt.id)); + } + type_sel.value = bin.type_id ?? ''; + + const dims_row = document.getElementById('bin-editor-dims-row'); + function sync_dims_row() { + const bt = all_bin_types.find(t => t.id === type_sel.value); + if (bt) { + document.getElementById('bin-editor-width').value = bt.phys_w; + document.getElementById('bin-editor-height').value = bt.phys_h; + dims_row.hidden = true; + } else { + document.getElementById('bin-editor-width').value = bin.phys_w ?? ''; + document.getElementById('bin-editor-height').value = bin.phys_h ?? ''; + dims_row.hidden = false; + } + } + type_sel.addEventListener('change', sync_dims_row); + sync_dims_row(); // Show dialog first so the canvas has correct layout dimensions before // load_image reads parentElement.clientWidth to size itself. @@ -2162,7 +2250,7 @@ function parse_url() { section = 'images'; } else if (p0 === 'bins') { section = 'bins'; - bin_tab = p1 === 'sources' ? 'sources' : 'bins'; + bin_tab = p1 === 'sources' ? 'sources' : p1 === 'types' ? 'types' : 'bins'; } else if (p0 === 'grids') { section = 'grids'; if (p1 === 'sources') { @@ -2255,7 +2343,7 @@ async function init() { const html = await fetch('/templates.html').then(r => r.text()); document.body.insertAdjacentHTML('beforeend', html); - for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker', 't-dialog-bin-editor']) { + for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker', 't-dialog-bin-editor', 't-dialog-bin-type']) { document.body.appendChild(document.getElementById(id).content.cloneNode(true)); } @@ -2352,22 +2440,30 @@ async function init() { bin_editor_bin_id = null; }); + document.getElementById('bt-cancel').addEventListener('click', () => { + document.getElementById('dialog-bin-type').close(); + }); + document.getElementById('bt-save').addEventListener('click', async () => { + try { + await bin_type_dialog_callback?.(); + document.getElementById('dialog-bin-type').close(); + } catch (err) { + alert(err.message); + } + }); + document.getElementById('bin-editor-save').addEventListener('click', async () => { const corners = bin_editor_instance?.get_corners(); - const name = document.getElementById('bin-editor-name').value.trim(); + const name = document.getElementById('bin-editor-name').value.trim() || 'Bin'; + const type_id = document.getElementById('bin-editor-type').value || null; const phys_w = parseFloat(document.getElementById('bin-editor-width').value) || null; const phys_h = parseFloat(document.getElementById('bin-editor-height').value) || null; if (!corners) { alert('Load an image first.'); return; } const id = bin_editor_bin_id; try { - let updated; - if (name) { - const r = await api.rename_bin(id, name); - updated = r.bin; - } - const r2 = await api.update_bin_corners(id, corners, phys_w, phys_h); - updated = r2.bin; - all_bins = all_bins.map(b => b.id === id ? updated : b); + await api.update_bin(id, { name, type_id }); + const r = await api.update_bin_corners(id, corners, phys_w, phys_h); + all_bins = all_bins.map(b => b.id === id ? r.bin : b); document.getElementById('dialog-bin-editor').close(); bin_editor_instance = null; bin_editor_bin_id = null; diff --git a/public/lib/api.mjs b/public/lib/api.mjs index ff90334..edad102 100644 --- a/public/lib/api.mjs +++ b/public/lib/api.mjs @@ -61,11 +61,17 @@ export async function upload_pdf(file, display_name, filename) { return data; } +// Bin types +export const get_bin_types = () => req('GET', '/api/bin-types'); +export const create_bin_type = (body) => req('POST', '/api/bin-types', body); +export const update_bin_type = (id, body) => req('PUT', `/api/bin-types/${id}`, body); +export const delete_bin_type = (id) => req('DELETE', `/api/bin-types/${id}`); + // Bins export const get_bins = () => req('GET', '/api/bins'); export const create_bin_from_source = (source_id, name) => req('POST', '/api/bins/from-source', { source_id, name }); export const get_bin = (id) => req('GET', `/api/bins/${id}`); -export const rename_bin = (id, name) => req('PUT', `/api/bins/${id}`, { name }); +export const update_bin = (id, body) => req('PUT', `/api/bins/${id}`, body); export const update_bin_corners = (id, corners, phys_w, phys_h) => req('PUT', `/api/bins/${id}/corners`, { corners, phys_w, phys_h }); export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`); diff --git a/public/style.css b/public/style.css index 6cf08e0..14d0d49 100644 --- a/public/style.css +++ b/public/style.css @@ -1993,3 +1993,43 @@ nav { border-radius: 4px; max-width: 100%; } + +.bin-types-list { + display: flex; + flex-direction: column; +} + +.bin-type-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); + gap: 1rem; +} + +.bin-type-info { + display: flex; + align-items: baseline; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.bin-type-name { + font-weight: 500; +} + +.bin-type-dims { + font-size: 0.8rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.bin-type-desc { + font-size: 0.8rem; + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/public/templates.html b/public/templates.html index 9e2bd81..0446227 100644 --- a/public/templates.html +++ b/public/templates.html @@ -617,11 +617,13 @@
+
+
@@ -629,9 +631,26 @@ + + + + +