/* * events.js * Event delegation and the central action dispatcher. * * Two delegated listeners (click + change) are attached to #app. * A third click listener is attached to #photo-queue-overlay (outside #app). * Both delegate through handle(action, dataset, event). * * Accordion helpers (getSiblingIds, accordionExpand) implement mobile * expand-only behaviour: opening one node collapses its siblings. * * Depends on: S, _bnd, _batchState, _photoQueue (state.js); * req (api.js); toast, isDesktop (helpers.js); * walkTree, removeNode, findNode, parseBounds (tree-render.js / * canvas-boundary.js); render, renderDetail, connectBatchWs * (init.js); startCropMode (canvas-crop.js); * triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js); * drawBnd (canvas-boundary.js) * Provides: handle(), getSiblingIds(), accordionExpand() */ // ── Accordion helpers ──────────────────────────────────────────────────────── function getSiblingIds(id, type) { if (!S.tree) return []; if (type === 'room') return S.tree.filter((r) => r.id !== id).map((r) => r.id); for (const r of S.tree) { if (type === 'cabinet' && r.cabinets.some((c) => c.id === id)) return r.cabinets.filter((c) => c.id !== id).map((c) => c.id); for (const c of r.cabinets) { if (type === 'shelf' && c.shelves.some((s) => s.id === id)) return c.shelves.filter((s) => s.id !== id).map((s) => s.id); } } return []; } function accordionExpand(id, type) { if (!isDesktop()) getSiblingIds(id, type).forEach((sid) => S.expanded.delete(sid)); S.expanded.add(id); } // ── Event delegation ───────────────────────────────────────────────────────── document.getElementById('app').addEventListener('click', async (e) => { const el = e.target.closest('[data-a]'); if (!el) return; const d = el.dataset; try { await handle(d.a, d, e); } catch (err) { toast('Error: ' + err.message); } }); document.getElementById('app').addEventListener('change', async (e) => { const el = e.target.closest('[data-a]'); if (!el) return; const d = el.dataset; try { await handle(d.a, d, e); } catch (err) { toast('Error: ' + err.message); } }); // Photo queue overlay is outside #app so needs its own listener document.getElementById('photo-queue-overlay').addEventListener('click', async (e) => { const el = e.target.closest('[data-a]'); if (!el) return; const d = el.dataset; try { await handle(d.a, d, e); } catch (err) { toast('Error: ' + err.message); } }); // ── Action dispatcher ──────────────────────────────────────────────────────── async function handle(action, d, e) { switch (action) { case 'select': { // Ignore if the click hit a button or editable inside the row if (e?.target?.closest('button,[contenteditable]')) return; if (!isDesktop()) { // Mobile: room/cabinet/shelf → expand-only (accordion); books → nothing if (d.type === 'room' || d.type === 'cabinet' || d.type === 'shelf') { accordionExpand(d.id, d.type); render(); } break; } S.selected = { type: d.type, id: d.id }; S._loading = {}; render(); break; } case 'deselect': { S.selected = null; render(); break; } case 'toggle': { if (!isDesktop()) { // Mobile: expand-only (no collapse to avoid accidental mistaps) accordionExpand(d.id, d.type); } else { if (S.expanded.has(d.id)) { S.expanded.delete(d.id); } else { S.expanded.add(d.id); } } render(); break; } // Rooms case 'add-room': { const r = await req('POST', '/api/rooms'); if (!S.tree) S.tree = []; S.tree.push({ ...r, cabinets: [] }); S.expanded.add(r.id); render(); break; } case 'del-room': { if (!confirm('Delete room and all contents?')) break; await req('DELETE', `/api/rooms/${d.id}`); removeNode('room', d.id); if (S.selected?.id === d.id) S.selected = null; render(); break; } // Cabinets case 'add-cabinet': { const c = await req('POST', `/api/rooms/${d.id}/cabinets`); S.tree.forEach((r) => { if (r.id === d.id) r.cabinets.push({ ...c, shelves: [] }); }); S.expanded.add(d.id); render(); break; // expand parent room } case 'del-cabinet': { if (!confirm('Delete cabinet and all contents?')) break; await req('DELETE', `/api/cabinets/${d.id}`); removeNode('cabinet', d.id); if (S.selected?.id === d.id) S.selected = null; render(); break; } // Shelves case 'add-shelf': { const cab = findNode(d.id); const prevCount = cab ? cab.shelves.length : 0; const s = await req('POST', `/api/cabinets/${d.id}/shelves`); S.tree.forEach((r) => r.cabinets.forEach((c) => { if (c.id === d.id) c.shelves.push({ ...s, books: [] }); }), ); if (prevCount > 0) { // Split last segment in half to make room for new shelf const bounds = parseBounds(cab.shelf_boundaries); const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0; const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000; const newBounds = [...bounds, newBound]; await req('PATCH', `/api/cabinets/${d.id}/boundaries`, { boundaries: newBounds }); S.tree.forEach((r) => r.cabinets.forEach((c) => { if (c.id === d.id) c.shelf_boundaries = JSON.stringify(newBounds); }), ); } S.expanded.add(d.id); render(); break; // expand parent cabinet } case 'del-shelf': { if (!confirm('Delete shelf and all books?')) break; await req('DELETE', `/api/shelves/${d.id}`); removeNode('shelf', d.id); if (S.selected?.id === d.id) S.selected = null; render(); break; } // Books case 'add-book': { const shelf = findNode(d.id); const prevCount = shelf ? shelf.books.length : 0; const b = await req('POST', `/api/shelves/${d.id}/books`); S.tree.forEach((r) => r.cabinets.forEach((c) => c.shelves.forEach((s) => { if (s.id === d.id) s.books.push(b); }), ), ); if (prevCount > 0) { // Split last segment in half to make room for new book const bounds = parseBounds(shelf.book_boundaries); const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0; const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000; const newBounds = [...bounds, newBound]; await req('PATCH', `/api/shelves/${d.id}/boundaries`, { boundaries: newBounds }); S.tree.forEach((r) => r.cabinets.forEach((c) => c.shelves.forEach((s) => { if (s.id === d.id) s.book_boundaries = JSON.stringify(newBounds); }), ), ); } S.expanded.add(d.id); render(); break; // expand parent shelf } case 'del-book': { if (!confirm('Delete this book?')) break; await req('DELETE', `/api/books/${d.id}`); removeNode('book', d.id); if (S.selected?.id === d.id) S.selected = null; render(); break; } case 'del-book-confirm': { if (!confirm('Delete this book?')) break; await req('DELETE', `/api/books/${d.id}`); removeNode('book', d.id); S.selected = null; render(); break; } case 'save-book': { const data = { title: document.getElementById('d-title')?.value || '', author: document.getElementById('d-author')?.value || '', year: document.getElementById('d-year')?.value || '', isbn: document.getElementById('d-isbn')?.value || '', publisher: document.getElementById('d-pub')?.value || '', notes: document.getElementById('d-notes')?.value || '', }; const res = await req('PUT', `/api/books/${d.id}`, data); walkTree((n) => { if (n.id === d.id) { Object.assign(n, data); n.ai_title = data.title; n.ai_author = data.author; n.ai_year = data.year; n.ai_isbn = data.isbn; n.ai_publisher = data.publisher; n.identification_status = res.identification_status ?? n.identification_status; } }); toast('Saved'); render(); break; } case 'run-plugin': { const key = `${d.plugin}:${d.id}`; // Capture any unsaved field edits before the first renderDetail() overwrites them. if (d.etype === 'books') { walkTree((n) => { if (n.id === d.id) { n.title = document.getElementById('d-title')?.value ?? n.title; n.author = document.getElementById('d-author')?.value ?? n.author; n.year = document.getElementById('d-year')?.value ?? n.year; n.isbn = document.getElementById('d-isbn')?.value ?? n.isbn; n.publisher = document.getElementById('d-pub')?.value ?? n.publisher; n.notes = document.getElementById('d-notes')?.value ?? n.notes; } }); } S._loading[key] = true; renderDetail(); try { const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`); walkTree((n) => { if (n.id !== d.id) return; if (d.etype === 'books') { // Server response must not overwrite user edits captured above. const saved = { title: n.title, author: n.author, year: n.year, isbn: n.isbn, publisher: n.publisher, notes: n.notes, }; Object.assign(n, res); Object.assign(n, saved); } else { Object.assign(n, res); } }); } catch (err) { toast(`${d.plugin} failed: ${err.message}`); } delete S._loading[key]; renderDetail(); break; } case 'select-bnd-plugin': { if (_bnd) { _bnd.selectedPlugin = e.target.value || null; drawBnd(); } break; } case 'accept-field': { const inp = document.getElementById(d.input); if (inp) inp.value = d.value; walkTree((n) => { if (n.id === d.id) n[d.field] = d.value; }); renderDetail(); break; } case 'dismiss-field': { const res = await req('POST', `/api/books/${d.id}/dismiss-field`, { field: d.field, value: d.value || '' }); walkTree((n) => { if (n.id === d.id) { n.candidates = JSON.stringify(res.candidates || []); if (!d.value) n[`ai_${d.field}`] = n[d.field] || ''; n.identification_status = res.identification_status ?? n.identification_status; } }); renderDetail(); break; } case 'identify-book': { const key = `identify:${d.id}`; S._loading[key] = true; renderDetail(); try { const res = await req('POST', `/api/books/${d.id}/identify`); walkTree((n) => { if (n.id !== d.id) return; const saved = { title: n.title, author: n.author, year: n.year, isbn: n.isbn, publisher: n.publisher, notes: n.notes, }; Object.assign(n, res); Object.assign(n, saved); }); } catch (err) { toast(`Identify failed: ${err.message}`); } delete S._loading[key]; renderDetail(); break; } case 'toggle-ai-blocks': { walkTree((n) => { if (n.id === d.id) _aiBlocksVisible[d.id] = !aiBlocksShown(n); }); renderDetail(); break; } case 'apply-ai-block': { let block; try { block = JSON.parse(d.block); } catch { break; } const fieldMap = { title: 'd-title', author: 'd-author', year: 'd-year', isbn: 'd-isbn', publisher: 'd-pub' }; for (const [field, inputId] of Object.entries(fieldMap)) { const v = (block[field] || '').trim(); if (!v) continue; const inp = document.getElementById(inputId); if (inp) inp.value = v; walkTree((n) => { if (n.id === d.id) n[field] = v; }); } renderDetail(); break; } case 'batch-start': { const res = await req('POST', '/api/batch'); if (res.already_running) { toast(res.added > 0 ? `Added ${res.added} book(s) to batch` : 'Batch already running'); if (!_batchWs) connectBatchWs(); break; } if (!res.started) { toast('No unidentified books'); break; } connectBatchWs(); renderDetail(); break; } case 'open-img-popup': { const popup = document.getElementById('img-popup'); if (!popup) break; document.getElementById('img-popup-img').src = d.src; popup.classList.add('open'); break; } // Photo case 'photo': triggerPhoto(d.type, d.id); break; // Crop case 'crop-start': startCropMode(d.type, d.id); break; // Photo queue case 'photo-queue-start': { const node = findNode(d.id); if (!node) break; const books = collectQueueBooks(node, d.type); if (!books.length) { toast('No unidentified books'); break; } _photoQueue = { books, index: 0, processing: false }; renderPhotoQueue(); break; } case 'photo-queue-take': { if (!_photoQueue) break; const book = _photoQueue.books[_photoQueue.index]; if (!book) break; triggerPhoto('book', book.id); break; } case 'photo-queue-skip': { if (!_photoQueue) break; _photoQueue.index++; renderPhotoQueue(); break; } case 'photo-queue-close': { _photoQueue = null; renderPhotoQueue(); break; } } }