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:
138
public/app.mjs
138
public/app.mjs
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user