From 38c2d89c9b5cdb02b593ce8f04b4748445da964e Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Wed, 1 Apr 2026 04:41:08 +0000 Subject: [PATCH] Add tabbed sub-views to bins section, create-bin-from-source flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bins section now mirrors the grids section with two tabs: - Bins: gallery of processed bin records - Sources: source images tagged with uses=['bin'], with upload and '+ Bin' button to create a bin record from an existing source image Server: POST /api/bins/from-source accepts source_id, creates bin record and adds 'bin' to the source image's uses array. URL state: /bins → bins tab, /bins/sources → sources tab. Co-Authored-By: Claude Sonnet 4.6 --- public/app.mjs | 117 +++++++++++++++++++++++++++++++++--------- public/lib/api.mjs | 3 +- public/templates.html | 18 +++++-- server.mjs | 34 ++++++++++++ 4 files changed, 142 insertions(+), 30 deletions(-) diff --git a/public/app.mjs b/public/app.mjs index 0dfd441..1e54709 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -29,7 +29,8 @@ let all_drafts = []; let all_templates = []; let all_pdfs = []; let all_bins = []; -let bin_editor_instance = null; // { bin, setup: Grid_Setup } +let bin_tab = 'bins'; // 'bins' | 'sources' +let bin_editor_instance = null; let bin_editor_bin_id = null; // --------------------------------------------------------------------------- @@ -1947,47 +1948,111 @@ function confirm_delete(message, on_confirm) { // --------------------------------------------------------------------------- function render_bins() { - const main = document.getElementById('main'); - main.innerHTML = ''; - const sec = document.getElementById('t-section-bins').content.cloneNode(true); - main.appendChild(sec); + let sec = document.getElementById('section-bins'); + if (!sec) { + const main = document.getElementById('main'); + main.replaceChildren(document.getElementById('t-section-bins').content.cloneNode(true)); + sec = document.getElementById('section-bins'); + qs(sec, '#btn-tab-bins').addEventListener('click', () => { + bin_tab = 'bins'; + history.replaceState(null, '', '/bins'); + update_bin_tabs(sec); + }); + qs(sec, '#btn-tab-bin-sources').addEventListener('click', () => { + bin_tab = 'sources'; + history.replaceState(null, '', '/bins/sources'); + update_bin_tabs(sec); + }); + qs(sec, '#bin-source-upload-input').addEventListener('change', async (e) => { + const files = [...e.target.files]; + if (!files.length) return; + for (const file of files) { + try { + const result = await api.upload_bin(file, file.name.replace(/\.[^.]+$/, '')); + all_bins.unshift(result.bin); + const src = all_sources.find(s => s.id === result.bin.source_id); + if (!src) { + const r2 = await api.get_source_images(); + all_sources = r2.sources; + } + } catch (err) { + alert(err.message); + } + } + e.target.value = ''; + render_bin_source_list(); + }); + } + + update_bin_tabs(sec); + render_bin_list(); + render_bin_source_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-upload-bin-sources').hidden = (bin_tab !== 'sources'); + qs(sec, '#tab-bins-content').hidden = (bin_tab !== 'bins'); + qs(sec, '#tab-bin-sources-content').hidden = (bin_tab !== 'sources'); +} + +function render_bin_list() { const gallery = document.getElementById('bin-gallery'); + if (!gallery) return; + gallery.replaceChildren(); for (const bin of all_bins) { - const card = document.getElementById('t-bin-card').content.cloneNode(true); - const img = card.querySelector('.bin-card-img'); - const img_wrap = card.querySelector('.bin-card-img-wrap'); - card.querySelector('.bin-card-name').textContent = bin.name; + const card = clone('t-bin-card'); + const img = qs(card, '.bin-card-img'); + const img_wrap = qs(card, '.bin-card-img-wrap'); + qs(card, '.bin-card-name').textContent = bin.name; if (bin.image_filename) { img.src = `/img/${bin.image_filename}`; img_wrap.classList.add('has-image'); } else { img.hidden = true; } - card.querySelector('.btn-edit').addEventListener('click', () => open_bin_editor(bin)); - card.querySelector('.btn-delete').addEventListener('click', () => { + qs(card, '.btn-edit').addEventListener('click', () => open_bin_editor(bin)); + qs(card, '.btn-delete').addEventListener('click', () => { confirm_delete(`Delete bin "${bin.name}"?`, async () => { await api.delete_bin(bin.id); all_bins = all_bins.filter(b => b.id !== bin.id); - render_bins(); + render_bin_list(); }); }); gallery.appendChild(card); } +} - document.getElementById('bin-upload-input').addEventListener('change', async (e) => { - const file = e.target.files[0]; - if (!file) return; - try { - const name = file.name.replace(/\.[^.]+$/, ''); - const result = await api.upload_bin(file, name); - all_bins.unshift(result.bin); - render_bins(); - open_bin_editor(result.bin); - } catch (err) { - alert(err.message); - } - }); +function render_bin_source_list() { + const list_el = document.getElementById('bin-source-image-list'); + if (!list_el) return; + const bin_sources = all_sources.filter(s => s.uses?.includes('bin')); + if (bin_sources.length === 0) { + const el = clone('t-empty-block'); + el.textContent = 'No bin source images yet. Upload photos of your bins.'; + list_el.replaceChildren(el); + return; + } + list_el.replaceChildren(...bin_sources.map(src => { + const card = build_source_card(src, false); + const create_btn = card.querySelector('.source-card-create-bin'); + create_btn.hidden = false; + create_btn.addEventListener('click', async () => { + try { + const result = await api.create_bin_from_source(src.id); + all_bins.unshift(result.bin); + render_bin_list(); + open_bin_editor(result.bin); + bin_tab = 'bins'; + update_bin_tabs(document.getElementById('section-bins')); + } catch (err) { + alert(err.message); + } + }); + return card; + })); } function open_bin_editor(bin) { @@ -2021,6 +2086,7 @@ function parse_url() { section = 'components'; grid_view_state = 'list'; grid_tab = 'grids'; + bin_tab = 'bins'; current_grid_id = null; current_panel_idx = null; grid_draft = null; @@ -2045,6 +2111,7 @@ function parse_url() { section = 'templates'; } else if (p0 === 'bins') { section = 'bins'; + bin_tab = p1 === 'sources' ? 'sources' : 'bins'; } else if (p0 === 'grids') { section = 'grids'; if (p1 === 'sources') { diff --git a/public/lib/api.mjs b/public/lib/api.mjs index 6a23090..fa38b67 100644 --- a/public/lib/api.mjs +++ b/public/lib/api.mjs @@ -62,7 +62,8 @@ export async function upload_pdf(file, display_name, filename) { } // Bins -export const get_bins = () => req('GET', '/api/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_corners = (id, corners) => req('PUT', `/api/bins/${id}/corners`, { corners }); diff --git a/public/templates.html b/public/templates.html index fa96d62..dc6facc 100644 --- a/public/templates.html +++ b/public/templates.html @@ -280,6 +280,7 @@
+ @@ -592,12 +593,21 @@ diff --git a/server.mjs b/server.mjs index 7974852..f82c0bd 100644 --- a/server.mjs +++ b/server.mjs @@ -662,6 +662,40 @@ app.get('/api/bins/:id', (req, res) => { ok(res, { bin }); }); +// Create a bin from an already-uploaded source image +app.post('/api/bins/from-source', (req, res) => { + const { source_id, name = '' } = req.body; + if (!source_id) return fail(res, 'source_id is required'); + const src = get_source_image(source_id); + if (!src) return fail(res, 'source image not found', 404); + + // Add 'bin' to uses if not already there + if (!src.uses?.includes('bin')) { + add_source_image({ ...src, uses: [...(src.uses ?? []), 'bin'] }); + } + + const mx = Math.round(src.width * 0.15); + const my = Math.round(src.height * 0.15); + const bin = { + id: generate_id(), + name: name.trim() || src.original_name?.replace(/\.[^.]+$/, '') || 'Bin', + source_id, + source_w: src.width, + source_h: src.height, + corners: [ + { x: mx, y: my }, + { x: src.width - mx, y: my }, + { x: src.width - mx, y: src.height - my }, + { x: mx, y: src.height - my }, + ], + image_filename: null, + created_at: Date.now(), + updated_at: Date.now(), + }; + set_bin(bin); + ok(res, { bin }); +}); + // Upload a source image for a bin and create the bin record (no processing yet) app.post('/api/bins', upload.single('image'), async (req, res) => { if (!req.file) return fail(res, 'no image uploaded');