Add bins feature: upload, de-perspective, gallery

- lib/storage.mjs: bin CRUD with bin: prefix
- lib/grid-image.mjs: compute_bin_size() capped at 1024px
- server.mjs: POST/GET/PUT/DELETE /api/bins routes; PUT /api/bins/:id/corners
  re-processes image via process_grid_image with rows=1 cols=1
- public/lib/api.mjs: bin API wrappers including upload_bin()
- public/index.html: Bins nav button
- public/templates.html: t-section-bins, t-bin-card, t-dialog-bin-editor
- public/app.mjs: render_bins(), open_bin_editor() using Grid_Setup,
  save/cancel wiring in init()
- public/style.css: bin gallery and card styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:28:03 +00:00
parent f370b6d48d
commit 28b4590903
8 changed files with 510 additions and 11 deletions

View File

@@ -28,13 +28,16 @@ let highlight_cell = null; // { row, col } — set when navigating from componen
let all_drafts = [];
let all_templates = [];
let all_pdfs = [];
let all_bins = [];
let bin_editor_instance = null; // { bin, setup: Grid_Setup }
let bin_editor_bin_id = null;
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function load_all() {
const [cf, ci, cmp, gr, dr, sr, ct, pd] = await Promise.all([
const [cf, ci, cmp, gr, dr, sr, ct, pd, bn] = await Promise.all([
api.get_fields(),
api.get_inventory(),
api.get_components(),
@@ -43,6 +46,7 @@ async function load_all() {
api.get_source_images(),
api.get_component_templates(),
api.get_pdfs(),
api.get_bins(),
]);
all_fields = cf.fields;
all_inventory = ci.entries;
@@ -52,6 +56,7 @@ async function load_all() {
all_sources = sr.sources;
all_templates = ct.templates;
all_pdfs = pd.pdfs;
all_bins = bn.bins;
compile_templates();
}
@@ -893,6 +898,35 @@ function build_source_card(src, selectable, on_select = null) {
img_el.src = `/img/${src.id}`;
qs(card, '.source-card-link').href = `/img/${src.id}`;
set_text(card, '.source-card-meta', [src.original_name, `${src.width}×${src.height}`].filter(Boolean).join(' · '));
const uses_el = qs(card, '.source-card-uses');
const KNOWN_USES = ['grid', 'bin'];
for (const use of KNOWN_USES) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `source-use-badge source-use-${use}`;
btn.textContent = use;
const active = (src.uses ?? []).includes(use);
btn.classList.toggle('source-use-inactive', !active);
btn.title = active ? `Remove "${use}" tag` : `Add "${use}" tag`;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const current = (src.uses ?? []);
const new_uses = current.includes(use)
? current.filter(u => u !== use)
: [...current, use];
try {
const result = await api.update_source_image_uses(src.id, new_uses);
src.uses = result.source.uses;
all_sources = all_sources.map(s => s.id === src.id ? { ...s, uses: src.uses } : s);
// Re-render just this card's badges
btn.classList.toggle('source-use-inactive', !src.uses.includes(use));
btn.title = src.uses.includes(use) ? `Remove "${use}" tag` : `Add "${use}" tag`;
} catch (err) {
alert(err.message);
}
});
uses_el.appendChild(btn);
}
if (selectable) {
card.classList.add('selectable');
@@ -1908,6 +1942,74 @@ function confirm_delete(message, on_confirm) {
dlg.showModal();
}
// ---------------------------------------------------------------------------
// Bins
// ---------------------------------------------------------------------------
function render_bins() {
const main = document.getElementById('main');
main.innerHTML = '';
const sec = document.getElementById('t-section-bins').content.cloneNode(true);
main.appendChild(sec);
const gallery = document.getElementById('bin-gallery');
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;
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', () => {
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();
});
});
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 open_bin_editor(bin) {
const dlg = document.getElementById('dialog-bin-editor');
if (!dlg) return;
bin_editor_bin_id = bin.id;
document.getElementById('bin-editor-name').value = bin.name;
const canvas = document.getElementById('bin-editor-canvas');
bin_editor_instance = new Grid_Setup(canvas);
bin_editor_instance.set_rows(1);
bin_editor_instance.set_cols(1);
bin_editor_instance.load_image(`/img/${bin.source_id}`).then(() => {
if (bin.corners) {
bin_editor_instance.set_corners(bin.corners);
}
});
dlg.showModal();
}
// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
@@ -1941,6 +2043,8 @@ function parse_url() {
section = 'fields';
} else if (p0 === 'templates') {
section = 'templates';
} else if (p0 === 'bins') {
section = 'bins';
} else if (p0 === 'grids') {
section = 'grids';
if (p1 === 'sources') {
@@ -2021,6 +2125,7 @@ function render() {
else if (section === 'fields') render_fields();
else if (section === 'grids') render_grids();
else if (section === 'templates') render_templates();
else if (section === 'bins') render_bins();
}
// ---------------------------------------------------------------------------
@@ -2031,7 +2136,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']) {
for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker', 't-dialog-bin-editor']) {
document.body.appendChild(document.getElementById(id).content.cloneNode(true));
}
@@ -2122,6 +2227,35 @@ async function init() {
document.getElementById('dialog-confirm').close();
});
document.getElementById('bin-editor-cancel').addEventListener('click', () => {
document.getElementById('dialog-bin-editor').close();
bin_editor_instance = null;
bin_editor_bin_id = null;
});
document.getElementById('bin-editor-save').addEventListener('click', async () => {
const corners = bin_editor_instance?.get_corners();
const name = document.getElementById('bin-editor-name').value.trim();
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);
updated = r2.bin;
all_bins = all_bins.map(b => b.id === id ? updated : b);
document.getElementById('dialog-bin-editor').close();
bin_editor_instance = null;
bin_editor_bin_id = null;
render_bins();
} catch (err) {
alert(err.message);
}
});
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => navigate('/' + btn.dataset.section));
});

View File

@@ -16,6 +16,7 @@
<button class="nav-btn" data-section="fields">Fields</button>
<button class="nav-btn" data-section="grids">Grids</button>
<button class="nav-btn" data-section="templates">Templates</button>
<button class="nav-btn" data-section="bins">Bins</button>
</nav>
<div class="maint-menu" id="maint-menu">
<button class="maint-toggle" id="maint-toggle" title="Maintenance"></button>

View File

@@ -35,8 +35,9 @@ export const update_grid_draft = (id, body) => req('PUT', `/api/grid-drafts/${i
export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`);
// Source images
export const get_source_images = () => req('GET', '/api/source-images');
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
export const get_source_images = () => req('GET', '/api/source-images');
export const update_source_image_uses = (id, uses) => req('PUT', `/api/source-images/${id}`, { uses });
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
// Component templates
export const get_component_templates = () => req('GET', '/api/component-templates');
@@ -60,6 +61,23 @@ export async function upload_pdf(file, display_name, filename) {
return data;
}
// Bins
export const get_bins = () => req('GET', '/api/bins');
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 });
export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`);
export async function upload_bin(file, name) {
const form = new FormData();
form.append('image', file);
if (name) form.append('name', name);
const res = await fetch('/api/bins', { method: 'POST', body: form });
const data = await res.json();
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
return data;
}
// Maintenance
export const maintenance_pdf_thumbs = () => req('POST', '/api/maintenance/pdf-thumbs');

View File

@@ -1085,12 +1085,52 @@ nav {
box-shadow: 0 0 0 3px rgba(91, 156, 246, 0.3);
}
.source-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
}
.source-card-meta {
font-size: 0.75rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.source-card-uses {
display: flex;
gap: 0.2rem;
flex-shrink: 0;
}
.source-use-badge {
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
text-transform: lowercase;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
border: none;
transition: opacity 0.1s;
}
.source-use-badge.source-use-inactive {
opacity: 0.25;
}
.source-use-grid {
background: rgba(91, 156, 246, 0.18);
color: #5b9cf6;
}
.source-use-bin {
background: rgba(91, 246, 156, 0.18);
color: #5bf69c;
}
.source-card-delete {
@@ -1806,3 +1846,75 @@ nav {
.detail-file-link:hover {
text-decoration: underline;
}
/* ===== BINS ===== */
.bin-gallery {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem 0;
}
.bin-card {
width: 220px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.bin-card-img-wrap {
width: 100%;
aspect-ratio: 4 / 3;
background: #1a1a1a;
position: relative;
overflow: hidden;
}
.bin-card-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.bin-card-unprocessed {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--text-muted);
background: rgba(0,0,0,0.5);
}
.bin-card-img-wrap.has-image .bin-card-unprocessed {
display: none;
}
.bin-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.5rem;
gap: 0.5rem;
min-height: 2rem;
}
.bin-card-name {
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.bin-editor-canvas {
display: block;
border-radius: 4px;
max-width: 100%;
}

View File

@@ -276,7 +276,10 @@
<a class="source-card-link" target="_blank" rel="noopener">
<img class="source-card-img" alt="">
</a>
<div class="source-card-meta"></div>
<div class="source-card-footer">
<div class="source-card-meta"></div>
<div class="source-card-uses"></div>
</div>
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete"></button>
</div>
</template>
@@ -585,6 +588,51 @@
</dialog>
</template>
<!-- ===== BINS SECTION ===== -->
<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
</label>
</div>
<div class="bin-gallery" id="bin-gallery"></div>
</section>
</template>
<template id="t-bin-card">
<div class="bin-card">
<div class="bin-card-img-wrap">
<img class="bin-card-img" alt="">
<div class="bin-card-unprocessed">Not processed</div>
</div>
<div class="bin-card-footer">
<span class="bin-card-name"></span>
<span class="row-actions">
<button class="btn-icon btn-edit" title="Edit corners"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</div>
</div>
</template>
<!-- ===== DIALOG: BIN EDITOR ===== -->
<template id="t-dialog-bin-editor">
<dialog id="dialog-bin-editor" class="app-dialog app-dialog-wide">
<h2 class="dialog-title">Edit bin</h2>
<div class="form-row">
<label>Name</label>
<input type="text" id="bin-editor-name" autocomplete="off">
</div>
<canvas id="bin-editor-canvas" class="bin-editor-canvas"></canvas>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="bin-editor-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="bin-editor-save">Save &amp; process</button>
</div>
</dialog>
</template>
<!-- ===== CELL INVENTORY OVERLAY ===== -->
<template id="t-cell-inventory">
<div class="cell-inventory-overlay" id="cell-inventory-overlay">