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:
101
public/app.mjs
101
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() {
|
||||
let sec = document.getElementById('section-bins');
|
||||
if (!sec) {
|
||||
const main = document.getElementById('main');
|
||||
main.innerHTML = '';
|
||||
const sec = document.getElementById('t-section-bins').content.cloneNode(true);
|
||||
main.appendChild(sec);
|
||||
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;
|
||||
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 name = file.name.replace(/\.[^.]+$/, '');
|
||||
const result = await api.upload_bin(file, name);
|
||||
const result = await api.create_bin_from_source(src.id);
|
||||
all_bins.unshift(result.bin);
|
||||
render_bins();
|
||||
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') {
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function upload_pdf(file, display_name, filename) {
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
|
||||
34
server.mjs
34
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');
|
||||
|
||||
Reference in New Issue
Block a user