Files
bookshelf/static/js/events.js
night f29678ebf1 Initial commit
Photo-based book cataloger with AI identification.
Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend;
vanilla JS SPA; OpenAI-compatible plugin system for boundary
detection, text recognition, and archive search.
2026-03-09 14:11:11 +03:00

284 lines
11 KiB
JavaScript

/*
* 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;
}
}
}