diff --git a/lib/grid-image.mjs b/lib/grid-image.mjs index d3755fe..b92ce3a 100644 --- a/lib/grid-image.mjs +++ b/lib/grid-image.mjs @@ -86,6 +86,22 @@ function bilinear_sample(pixels, width, height, x, y, out, out_idx) { // 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 export function compute_cell_size(corners, rows, cols) { const [tl, tr, br, bl] = corners; diff --git a/lib/storage.mjs b/lib/storage.mjs index 578d5c4..1bb6849 100644 --- a/lib/storage.mjs +++ b/lib/storage.mjs @@ -174,6 +174,28 @@ export function find_pdf_references(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 --- export function list_grid_images() { diff --git a/public/app.mjs b/public/app.mjs index 0ded26c..0dfd441 100644 --- a/public/app.mjs +++ b/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)); }); diff --git a/public/index.html b/public/index.html index 5afd9a3..e7c8bbb 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,7 @@ +
diff --git a/public/lib/api.mjs b/public/lib/api.mjs index e8998d2..6a23090 100644 --- a/public/lib/api.mjs +++ b/public/lib/api.mjs @@ -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'); diff --git a/public/style.css b/public/style.css index 242f8bb..e6a6837 100644 --- a/public/style.css +++ b/public/style.css @@ -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%; +} diff --git a/public/templates.html b/public/templates.html index 7779289..fa96d62 100644 --- a/public/templates.html +++ b/public/templates.html @@ -276,7 +276,10 @@ -
+
@@ -585,6 +588,51 @@ + + + + + + + +