Add tabbed sub-views to bins section, create-bin-from-source flow

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:41:08 +00:00
parent e183988acb
commit 38c2d89c9b
4 changed files with 142 additions and 30 deletions

View File

@@ -29,7 +29,8 @@ let all_drafts = [];
let all_templates = []; let all_templates = [];
let all_pdfs = []; let all_pdfs = [];
let all_bins = []; 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; let bin_editor_bin_id = null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1947,47 +1948,111 @@ function confirm_delete(message, on_confirm) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function render_bins() { function render_bins() {
let sec = document.getElementById('section-bins');
if (!sec) {
const main = document.getElementById('main'); const main = document.getElementById('main');
main.innerHTML = ''; main.replaceChildren(document.getElementById('t-section-bins').content.cloneNode(true));
const sec = document.getElementById('t-section-bins').content.cloneNode(true); sec = document.getElementById('section-bins');
main.appendChild(sec);
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'); const gallery = document.getElementById('bin-gallery');
if (!gallery) return;
gallery.replaceChildren();
for (const bin of all_bins) { for (const bin of all_bins) {
const card = document.getElementById('t-bin-card').content.cloneNode(true); const card = clone('t-bin-card');
const img = card.querySelector('.bin-card-img'); const img = qs(card, '.bin-card-img');
const img_wrap = card.querySelector('.bin-card-img-wrap'); const img_wrap = qs(card, '.bin-card-img-wrap');
card.querySelector('.bin-card-name').textContent = bin.name; qs(card, '.bin-card-name').textContent = bin.name;
if (bin.image_filename) { if (bin.image_filename) {
img.src = `/img/${bin.image_filename}`; img.src = `/img/${bin.image_filename}`;
img_wrap.classList.add('has-image'); img_wrap.classList.add('has-image');
} else { } else {
img.hidden = true; img.hidden = true;
} }
card.querySelector('.btn-edit').addEventListener('click', () => open_bin_editor(bin)); qs(card, '.btn-edit').addEventListener('click', () => open_bin_editor(bin));
card.querySelector('.btn-delete').addEventListener('click', () => { qs(card, '.btn-delete').addEventListener('click', () => {
confirm_delete(`Delete bin "${bin.name}"?`, async () => { confirm_delete(`Delete bin "${bin.name}"?`, async () => {
await api.delete_bin(bin.id); await api.delete_bin(bin.id);
all_bins = all_bins.filter(b => b.id !== bin.id); all_bins = all_bins.filter(b => b.id !== bin.id);
render_bins(); render_bin_list();
}); });
}); });
gallery.appendChild(card); gallery.appendChild(card);
} }
}
document.getElementById('bin-upload-input').addEventListener('change', async (e) => { function render_bin_source_list() {
const file = e.target.files[0]; const list_el = document.getElementById('bin-source-image-list');
if (!file) return; 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 { try {
const name = file.name.replace(/\.[^.]+$/, ''); const result = await api.create_bin_from_source(src.id);
const result = await api.upload_bin(file, name);
all_bins.unshift(result.bin); all_bins.unshift(result.bin);
render_bins(); render_bin_list();
open_bin_editor(result.bin); open_bin_editor(result.bin);
bin_tab = 'bins';
update_bin_tabs(document.getElementById('section-bins'));
} catch (err) { } catch (err) {
alert(err.message); alert(err.message);
} }
}); });
return card;
}));
} }
function open_bin_editor(bin) { function open_bin_editor(bin) {
@@ -2021,6 +2086,7 @@ function parse_url() {
section = 'components'; section = 'components';
grid_view_state = 'list'; grid_view_state = 'list';
grid_tab = 'grids'; grid_tab = 'grids';
bin_tab = 'bins';
current_grid_id = null; current_grid_id = null;
current_panel_idx = null; current_panel_idx = null;
grid_draft = null; grid_draft = null;
@@ -2045,6 +2111,7 @@ function parse_url() {
section = 'templates'; section = 'templates';
} else if (p0 === 'bins') { } else if (p0 === 'bins') {
section = 'bins'; section = 'bins';
bin_tab = p1 === 'sources' ? 'sources' : 'bins';
} else if (p0 === 'grids') { } else if (p0 === 'grids') {
section = 'grids'; section = 'grids';
if (p1 === 'sources') { if (p1 === 'sources') {

View File

@@ -63,6 +63,7 @@ export async function upload_pdf(file, display_name, filename) {
// Bins // 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 get_bin = (id) => req('GET', `/api/bins/${id}`);
export const rename_bin = (id, name) => req('PUT', `/api/bins/${id}`, { name }); 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 }); export const update_bin_corners = (id, corners) => req('PUT', `/api/bins/${id}/corners`, { corners });

View File

@@ -280,6 +280,7 @@
<div class="source-card-meta"></div> <div class="source-card-meta"></div>
<div class="source-card-uses"></div> <div class="source-card-uses"></div>
</div> </div>
<button type="button" class="btn btn-secondary btn-sm source-card-create-bin" hidden>+ Bin</button>
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete"></button> <button type="button" class="btn-icon btn-danger source-card-delete" title="Delete"></button>
</div> </div>
</template> </template>
@@ -592,12 +593,21 @@
<template id="t-section-bins"> <template id="t-section-bins">
<section class="section" id="section-bins"> <section class="section" id="section-bins">
<div class="section-toolbar"> <div class="section-toolbar">
<label class="btn btn-primary"> <div class="tab-bar">
<input type="file" id="bin-upload-input" accept="image/*" hidden> <button class="tab-btn" id="btn-tab-bins">Bins</button>
+ Upload bin photo <button class="tab-btn" id="btn-tab-bin-sources">Source images</button>
</div>
<label class="btn btn-secondary" id="btn-upload-bin-sources" hidden>
+ Upload
<input type="file" accept="image/*" multiple hidden id="bin-source-upload-input">
</label> </label>
</div> </div>
<div id="tab-bins-content">
<div class="bin-gallery" id="bin-gallery"></div> <div class="bin-gallery" id="bin-gallery"></div>
</div>
<div id="tab-bin-sources-content" hidden>
<div id="bin-source-image-list" class="source-gallery"></div>
</div>
</section> </section>
</template> </template>

View File

@@ -662,6 +662,40 @@ app.get('/api/bins/:id', (req, res) => {
ok(res, { bin }); 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) // 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) => { app.post('/api/bins', upload.single('image'), async (req, res) => {
if (!req.file) return fail(res, 'no image uploaded'); if (!req.file) return fail(res, 'no image uploaded');