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:
@@ -86,6 +86,22 @@ function bilinear_sample(pixels, width, height, x, y, out, out_idx) {
|
|||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Compute natural size for a single de-perspectived bin image (cap at 1024px)
|
||||||
|
export function compute_bin_size(corners) {
|
||||||
|
const [tl, tr, br, bl] = corners;
|
||||||
|
const top_w = Math.hypot(tr.x - tl.x, tr.y - tl.y);
|
||||||
|
const bot_w = Math.hypot(br.x - bl.x, br.y - bl.y);
|
||||||
|
const left_h = Math.hypot(bl.x - tl.x, bl.y - tl.y);
|
||||||
|
const rgt_h = Math.hypot(br.x - tr.x, br.y - tr.y);
|
||||||
|
const raw_w = Math.max(top_w, bot_w);
|
||||||
|
const raw_h = Math.max(left_h, rgt_h);
|
||||||
|
const scale = Math.min(1.0, 1024 / Math.max(raw_w, raw_h));
|
||||||
|
return {
|
||||||
|
bin_w: Math.round(Math.max(48, raw_w * scale)),
|
||||||
|
bin_h: Math.round(Math.max(48, raw_h * scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Compute natural cell size from corner quadrilateral + grid dimensions
|
// Compute natural cell size from corner quadrilateral + grid dimensions
|
||||||
export function compute_cell_size(corners, rows, cols) {
|
export function compute_cell_size(corners, rows, cols) {
|
||||||
const [tl, tr, br, bl] = corners;
|
const [tl, tr, br, bl] = corners;
|
||||||
|
|||||||
@@ -174,6 +174,28 @@ export function find_pdf_references(pdf_id) {
|
|||||||
return list_components().filter(c => c.file_ids?.includes(pdf_id));
|
return list_components().filter(c => c.file_ids?.includes(pdf_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bins ---
|
||||||
|
|
||||||
|
export function list_bins() {
|
||||||
|
const result = [];
|
||||||
|
for (const [key] of store.data.entries()) {
|
||||||
|
if (key.startsWith('bin:')) result.push(store.get(key));
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_bin(id) {
|
||||||
|
return store.get(`bin:${id}`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set_bin(bin) {
|
||||||
|
store.set(`bin:${bin.id}`, bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delete_bin(id) {
|
||||||
|
return store.delete(`bin:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Grid images ---
|
// --- Grid images ---
|
||||||
|
|
||||||
export function list_grid_images() {
|
export function list_grid_images() {
|
||||||
|
|||||||
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_drafts = [];
|
||||||
let all_templates = [];
|
let all_templates = [];
|
||||||
let all_pdfs = [];
|
let all_pdfs = [];
|
||||||
|
let all_bins = [];
|
||||||
|
let bin_editor_instance = null; // { bin, setup: Grid_Setup }
|
||||||
|
let bin_editor_bin_id = null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data loading
|
// Data loading
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function load_all() {
|
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_fields(),
|
||||||
api.get_inventory(),
|
api.get_inventory(),
|
||||||
api.get_components(),
|
api.get_components(),
|
||||||
@@ -43,6 +46,7 @@ async function load_all() {
|
|||||||
api.get_source_images(),
|
api.get_source_images(),
|
||||||
api.get_component_templates(),
|
api.get_component_templates(),
|
||||||
api.get_pdfs(),
|
api.get_pdfs(),
|
||||||
|
api.get_bins(),
|
||||||
]);
|
]);
|
||||||
all_fields = cf.fields;
|
all_fields = cf.fields;
|
||||||
all_inventory = ci.entries;
|
all_inventory = ci.entries;
|
||||||
@@ -52,6 +56,7 @@ async function load_all() {
|
|||||||
all_sources = sr.sources;
|
all_sources = sr.sources;
|
||||||
all_templates = ct.templates;
|
all_templates = ct.templates;
|
||||||
all_pdfs = pd.pdfs;
|
all_pdfs = pd.pdfs;
|
||||||
|
all_bins = bn.bins;
|
||||||
compile_templates();
|
compile_templates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,6 +898,35 @@ function build_source_card(src, selectable, on_select = null) {
|
|||||||
img_el.src = `/img/${src.id}`;
|
img_el.src = `/img/${src.id}`;
|
||||||
qs(card, '.source-card-link').href = `/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(' · '));
|
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) {
|
if (selectable) {
|
||||||
card.classList.add('selectable');
|
card.classList.add('selectable');
|
||||||
@@ -1908,6 +1942,74 @@ function confirm_delete(message, on_confirm) {
|
|||||||
dlg.showModal();
|
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
|
// Routing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1941,6 +2043,8 @@ function parse_url() {
|
|||||||
section = 'fields';
|
section = 'fields';
|
||||||
} else if (p0 === 'templates') {
|
} else if (p0 === 'templates') {
|
||||||
section = 'templates';
|
section = 'templates';
|
||||||
|
} else if (p0 === 'bins') {
|
||||||
|
section = 'bins';
|
||||||
} else if (p0 === 'grids') {
|
} else if (p0 === 'grids') {
|
||||||
section = 'grids';
|
section = 'grids';
|
||||||
if (p1 === 'sources') {
|
if (p1 === 'sources') {
|
||||||
@@ -2021,6 +2125,7 @@ function render() {
|
|||||||
else if (section === 'fields') render_fields();
|
else if (section === 'fields') render_fields();
|
||||||
else if (section === 'grids') render_grids();
|
else if (section === 'grids') render_grids();
|
||||||
else if (section === 'templates') render_templates();
|
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());
|
const html = await fetch('/templates.html').then(r => r.text());
|
||||||
document.body.insertAdjacentHTML('beforeend', html);
|
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));
|
document.body.appendChild(document.getElementById(id).content.cloneNode(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2122,6 +2227,35 @@ async function init() {
|
|||||||
document.getElementById('dialog-confirm').close();
|
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 => {
|
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => navigate('/' + btn.dataset.section));
|
btn.addEventListener('click', () => navigate('/' + btn.dataset.section));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<button class="nav-btn" data-section="fields">Fields</button>
|
<button class="nav-btn" data-section="fields">Fields</button>
|
||||||
<button class="nav-btn" data-section="grids">Grids</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="templates">Templates</button>
|
||||||
|
<button class="nav-btn" data-section="bins">Bins</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="maint-menu" id="maint-menu">
|
<div class="maint-menu" id="maint-menu">
|
||||||
<button class="maint-toggle" id="maint-toggle" title="Maintenance">⚙</button>
|
<button class="maint-toggle" id="maint-toggle" title="Maintenance">⚙</button>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`
|
|||||||
|
|
||||||
// Source images
|
// Source images
|
||||||
export const get_source_images = () => req('GET', '/api/source-images');
|
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}`);
|
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
|
||||||
|
|
||||||
// Component templates
|
// Component templates
|
||||||
@@ -60,6 +61,23 @@ export async function upload_pdf(file, display_name, filename) {
|
|||||||
return data;
|
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
|
// Maintenance
|
||||||
export const maintenance_pdf_thumbs = () => req('POST', '/api/maintenance/pdf-thumbs');
|
export const maintenance_pdf_thumbs = () => req('POST', '/api/maintenance/pdf-thumbs');
|
||||||
|
|
||||||
|
|||||||
112
public/style.css
112
public/style.css
@@ -1085,12 +1085,52 @@ nav {
|
|||||||
box-shadow: 0 0 0 3px rgba(91, 156, 246, 0.3);
|
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 {
|
.source-card-meta {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.source-card-delete {
|
||||||
@@ -1806,3 +1846,75 @@ nav {
|
|||||||
.detail-file-link:hover {
|
.detail-file-link:hover {
|
||||||
text-decoration: underline;
|
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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,7 +276,10 @@
|
|||||||
<a class="source-card-link" target="_blank" rel="noopener">
|
<a class="source-card-link" target="_blank" rel="noopener">
|
||||||
<img class="source-card-img" alt="">
|
<img class="source-card-img" alt="">
|
||||||
</a>
|
</a>
|
||||||
|
<div class="source-card-footer">
|
||||||
<div class="source-card-meta"></div>
|
<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>
|
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -585,6 +588,51 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</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 & process</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ===== CELL INVENTORY OVERLAY ===== -->
|
<!-- ===== CELL INVENTORY OVERLAY ===== -->
|
||||||
<template id="t-cell-inventory">
|
<template id="t-cell-inventory">
|
||||||
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
|
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
|
||||||
|
|||||||
160
server.mjs
160
server.mjs
@@ -8,7 +8,7 @@ import { extname, join } from 'node:path';
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { generate_id } from './lib/ids.mjs';
|
import { generate_id } from './lib/ids.mjs';
|
||||||
import { compute_cell_size, process_grid_image } from './lib/grid-image.mjs';
|
import { compute_cell_size, compute_bin_size, process_grid_image } from './lib/grid-image.mjs';
|
||||||
import {
|
import {
|
||||||
list_fields, get_field, set_field, delete_field,
|
list_fields, get_field, set_field, delete_field,
|
||||||
list_components, get_component, set_component, delete_component,
|
list_components, get_component, set_component, delete_component,
|
||||||
@@ -18,11 +18,35 @@ import {
|
|||||||
list_grid_images, get_grid_image, set_grid_image, delete_grid_image,
|
list_grid_images, get_grid_image, set_grid_image, delete_grid_image,
|
||||||
list_component_templates, get_component_template, set_component_template, delete_component_template,
|
list_component_templates, get_component_template, set_component_template, delete_component_template,
|
||||||
list_pdfs, get_pdf, set_pdf, delete_pdf,
|
list_pdfs, get_pdf, set_pdf, delete_pdf,
|
||||||
|
list_bins, get_bin, set_bin, delete_bin,
|
||||||
} from './lib/storage.mjs';
|
} from './lib/storage.mjs';
|
||||||
|
|
||||||
mkdirSync('./data/images', { recursive: true });
|
mkdirSync('./data/images', { recursive: true });
|
||||||
mkdirSync('./data/pdfs', { recursive: true });
|
mkdirSync('./data/pdfs', { recursive: true });
|
||||||
|
|
||||||
|
// Migration: backfill uses[] on existing source images, and register any bin
|
||||||
|
// sources that predate the unified gallery.
|
||||||
|
(function migrate_source_images() {
|
||||||
|
for (const src of list_source_images()) {
|
||||||
|
if (!src.uses) {
|
||||||
|
add_source_image({ ...src, uses: ['grid'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const bin of list_bins()) {
|
||||||
|
if (!get_source_image(bin.source_id)) {
|
||||||
|
// Source image missing from KV — re-register it
|
||||||
|
add_source_image({
|
||||||
|
id: bin.source_id,
|
||||||
|
original_name: '',
|
||||||
|
width: bin.source_w ?? 0,
|
||||||
|
height: bin.source_h ?? 0,
|
||||||
|
uses: ['bin'],
|
||||||
|
created_at: bin.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/img', express.static('./data/images'));
|
app.use('/img', express.static('./data/images'));
|
||||||
@@ -63,6 +87,17 @@ function remove_image_file(img_id) {
|
|||||||
try { unlinkSync(join('./data/images', img_id)); } catch {}
|
try { unlinkSync(join('./data/images', img_id)); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to rename temp file to the original name; falls back to temp name on collision.
|
||||||
|
// Returns the final filename (basename only).
|
||||||
|
function settle_image_filename(temp_filename, original_name) {
|
||||||
|
const preferred = original_name || temp_filename;
|
||||||
|
const dest = join('./data/images', preferred);
|
||||||
|
if (rename_no_replace(join('./data/images', temp_filename), dest)) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
return temp_filename;
|
||||||
|
}
|
||||||
|
|
||||||
const MV_SYNC = new URL('./tools/mv-sync', import.meta.url).pathname;
|
const MV_SYNC = new URL('./tools/mv-sync', import.meta.url).pathname;
|
||||||
|
|
||||||
// Atomically rename src -> dst, failing if dst already exists.
|
// Atomically rename src -> dst, failing if dst already exists.
|
||||||
@@ -328,12 +363,14 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
|
|||||||
if (!req.files?.length) return fail(res, 'no files');
|
if (!req.files?.length) return fail(res, 'no files');
|
||||||
const added = [];
|
const added = [];
|
||||||
for (const file of req.files) {
|
for (const file of req.files) {
|
||||||
const meta = await sharp(file.path).metadata();
|
const final_name = settle_image_filename(file.filename, file.originalname);
|
||||||
|
const meta = await sharp(join('./data/images', final_name)).metadata();
|
||||||
const src = {
|
const src = {
|
||||||
id: file.filename,
|
id: final_name,
|
||||||
original_name: file.originalname,
|
original_name: file.originalname,
|
||||||
width: meta.width,
|
width: meta.width,
|
||||||
height: meta.height,
|
height: meta.height,
|
||||||
|
uses: ['grid'],
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
add_source_image(src);
|
add_source_image(src);
|
||||||
@@ -342,15 +379,26 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
|
|||||||
ok(res, { sources: added });
|
ok(res, { sources: added });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/source-images/:id', (req, res) => {
|
||||||
|
const src = get_source_image(req.params.id);
|
||||||
|
if (!src) return fail(res, 'not found', 404);
|
||||||
|
const { uses } = req.body;
|
||||||
|
if (!Array.isArray(uses)) return fail(res, 'uses must be an array');
|
||||||
|
const updated = { ...src, uses };
|
||||||
|
add_source_image(updated);
|
||||||
|
ok(res, { source: updated });
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/source-images/:id', (req, res) => {
|
app.delete('/api/source-images/:id', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
if (!get_source_image(id)) return fail(res, 'not found', 404);
|
if (!get_source_image(id)) return fail(res, 'not found', 404);
|
||||||
const grids = list_grid_images();
|
const in_grid = list_grid_images().find(g =>
|
||||||
const in_use = grids.find(g =>
|
|
||||||
g.source_id === id ||
|
g.source_id === id ||
|
||||||
(g.panels && g.panels.some(p => p.source_id === id))
|
(g.panels && g.panels.some(p => p.source_id === id))
|
||||||
);
|
);
|
||||||
if (in_use) return fail(res, `In use by grid "${in_use.name}"`, 409);
|
if (in_grid) return fail(res, `In use by grid "${in_grid.name}"`, 409);
|
||||||
|
const in_bin = list_bins().find(b => b.source_id === id);
|
||||||
|
if (in_bin) return fail(res, `In use by bin "${in_bin.name}"`, 409);
|
||||||
remove_image_file(id);
|
remove_image_file(id);
|
||||||
delete_source_image(id);
|
delete_source_image(id);
|
||||||
ok(res);
|
ok(res);
|
||||||
@@ -600,6 +648,106 @@ app.post('/api/maintenance/pdf-thumbs', (req, res) => {
|
|||||||
ok(res, { generated, total: pdfs.length });
|
ok(res, { generated, total: pdfs.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/bins', (req, res) => {
|
||||||
|
ok(res, { bins: list_bins() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/bins/:id', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
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');
|
||||||
|
const { name = '' } = req.body;
|
||||||
|
const final_name = settle_image_filename(req.file.filename, req.file.originalname);
|
||||||
|
const meta = await sharp(join('./data/images', final_name)).metadata();
|
||||||
|
|
||||||
|
// Register in unified source image gallery
|
||||||
|
const src = {
|
||||||
|
id: final_name,
|
||||||
|
original_name: req.file.originalname,
|
||||||
|
width: meta.width,
|
||||||
|
height: meta.height,
|
||||||
|
uses: ['bin'],
|
||||||
|
created_at: Date.now(),
|
||||||
|
};
|
||||||
|
add_source_image(src);
|
||||||
|
|
||||||
|
const mx = Math.round(meta.width * 0.15);
|
||||||
|
const my = Math.round(meta.height * 0.15);
|
||||||
|
const default_corners = [
|
||||||
|
{ x: mx, y: my },
|
||||||
|
{ x: meta.width - mx, y: my },
|
||||||
|
{ x: meta.width - mx, y: meta.height - my },
|
||||||
|
{ x: mx, y: meta.height - my },
|
||||||
|
];
|
||||||
|
const bin = {
|
||||||
|
id: generate_id(),
|
||||||
|
name: name.trim() || 'Bin',
|
||||||
|
source_id: final_name,
|
||||||
|
source_w: meta.width,
|
||||||
|
source_h: meta.height,
|
||||||
|
corners: default_corners,
|
||||||
|
image_filename: null,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
set_bin(bin);
|
||||||
|
ok(res, { bin });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update corners (and re-process image)
|
||||||
|
app.put('/api/bins/:id/corners', async (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
const { corners } = req.body;
|
||||||
|
if (!corners || corners.length !== 4) return fail(res, 'corners must be array of 4 points');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete old processed image if any
|
||||||
|
if (bin.image_filename) remove_image_file(bin.image_filename);
|
||||||
|
|
||||||
|
const source_path = join('./data/images', bin.source_id);
|
||||||
|
const { bin_w, bin_h } = compute_bin_size(corners);
|
||||||
|
const cells = await process_grid_image(source_path, corners, 1, 1, bin_w, bin_h, './data/images');
|
||||||
|
const image_filename = cells[0][0];
|
||||||
|
|
||||||
|
const updated = { ...bin, corners, image_filename, bin_w, bin_h, updated_at: Date.now() };
|
||||||
|
set_bin(updated);
|
||||||
|
ok(res, { bin: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
fail(res, err.message, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update name only
|
||||||
|
app.put('/api/bins/:id', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
const { name } = req.body;
|
||||||
|
const updated = { ...bin, updated_at: Date.now() };
|
||||||
|
if (name !== undefined) updated.name = name.trim() || 'Bin';
|
||||||
|
set_bin(updated);
|
||||||
|
ok(res, { bin: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/bins/:id', (req, res) => {
|
||||||
|
const bin = get_bin(req.params.id);
|
||||||
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
|
if (bin.image_filename) remove_image_file(bin.image_filename);
|
||||||
|
remove_image_file(bin.source_id);
|
||||||
|
delete_bin(bin.id);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
// SPA fallback — serve index.html for any non-API, non-asset path
|
// SPA fallback — serve index.html for any non-API, non-asset path
|
||||||
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
|
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
|
||||||
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));
|
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));
|
||||||
|
|||||||
Reference in New Issue
Block a user