/* * 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, startBatchPolling * (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}`; 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) 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 'batch-start': { const res = await req('POST', '/api/batch'); if (res.already_running) { toast('Batch already running'); break; } if (!res.started) { toast('No unidentified books'); break; } _batchState = {running: true, total: res.total, done: 0, errors: 0, current: ''}; startBatchPolling(); renderDetail(); 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; } } }