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_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') {

View File

@@ -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 });

View File

@@ -280,6 +280,7 @@
<div class="source-card-meta"></div>
<div class="source-card-uses"></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>
</div>
</template>
@@ -592,12 +593,21 @@
<template id="t-section-bins">
<section class="section" id="section-bins">
<div class="section-toolbar">
<label class="btn btn-primary">
<input type="file" id="bin-upload-input" accept="image/*" hidden>
+ Upload bin photo
<div class="tab-bar">
<button class="tab-btn" id="btn-tab-bins">Bins</button>
<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>
</div>
<div class="bin-gallery" id="bin-gallery"></div>
<div id="tab-bins-content">
<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>
</template>