Files
bookshelf/static/js/photo.js
Petr Polezhaev 084d1aebd5 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:17:13 +03:00

139 lines
5.8 KiB
JavaScript

/*
* photo.js
* Photo upload for all entity types and the mobile Photo Queue feature.
*
* Photo upload:
* triggerPhoto(type, id) — opens the hidden file input, sets _photoTarget.
* The 'change' handler uploads via multipart POST, updates the tree node,
* and on mobile automatically runs the full AI pipeline for books
* (POST /api/books/{id}/process).
*
* Photo Queue (mobile-only UI):
* collectQueueBooks(node, type) — collects all non-approved books in tree
* order (top-to-bottom within each shelf, left-to-right across shelves).
* renderPhotoQueue() — updates the #photo-queue-overlay DOM in-place.
* Queue flow: show spine → tap camera → upload + process → auto-advance.
* Queue is stored in _photoQueue (state.js) so events.js can control it.
*
* Depends on: S, _photoQueue (state.js); req, toast (api.js / helpers.js);
* walkTree, findNode, esc (tree-render.js / helpers.js);
* isDesktop, render (helpers.js / init.js)
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
*/
// ── Photo Queue ──────────────────────────────────────────────────────────────
function collectQueueBooks(node, type) {
const books = [];
function collect(n, t) {
if (t === 'book') {
if (n.identification_status !== 'user_approved') books.push(n);
return;
}
if (t === 'room') n.cabinets.forEach(c => collect(c, 'cabinet'));
if (t === 'cabinet') n.shelves.forEach(s => collect(s, 'shelf'));
if (t === 'shelf') n.books.forEach(b => collect(b, 'book'));
}
collect(node, type);
return books;
}
function renderPhotoQueue() {
const el = document.getElementById('photo-queue-overlay');
if (!el) return;
if (!_photoQueue) { el.style.display = 'none'; el.innerHTML = ''; return; }
const {books, index, processing} = _photoQueue;
el.style.display = 'flex';
if (index >= books.length) {
el.innerHTML = `<div class="pq-hdr">
<button class="hbtn" data-a="photo-queue-close">✕</button>
<span class="pq-hdr-title">Photo Queue</span>
<span style="min-width:34px"></span>
</div>
<div class="pq-spine-wrap" style="text-align:center">
<div style="font-size:3rem">✓</div>
<div style="font-size:1.1rem;color:#86efac;font-weight:600">All done!</div>
<div style="font-size:.82rem;color:#94a3b8;margin-top:4px">All ${books.length} book${books.length !== 1 ? 's' : ''} photographed</div>
<button class="btn btn-p" style="margin-top:20px" data-a="photo-queue-close">Close</button>
</div>`;
return;
}
const book = books[index];
el.innerHTML = `<div class="pq-hdr">
<button class="hbtn" data-a="photo-queue-close">✕</button>
<span class="pq-hdr-title">${index + 1} / ${books.length}</span>
<span style="min-width:34px"></span>
</div>
<div class="pq-spine-wrap">
<img class="pq-spine-img" src="/api/books/${book.id}/spine?t=${Date.now()}" alt="Spine"
onerror="this.style.display='none'">
<div class="pq-book-name">${esc(book.title || '—')}</div>
</div>
<div class="pq-actions">
<button class="pq-skip-btn" data-a="photo-queue-skip">Skip</button>
<button class="pq-camera-btn" data-a="photo-queue-take">📷</button>
</div>
${processing ? '<div class="pq-processing"><div class="spinner"></div><span>Processing…</span></div>' : ''}`;
}
// ── Photo upload ─────────────────────────────────────────────────────────────
const gphoto = document.getElementById('gphoto');
function triggerPhoto(type, id) {
S._photoTarget = {type, id};
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture','environment');
else gphoto.removeAttribute('capture');
gphoto.value = '';
gphoto.click();
}
gphoto.addEventListener('change', async () => {
const file = gphoto.files[0];
if (!file || !S._photoTarget) return;
const {type, id} = S._photoTarget;
S._photoTarget = null;
const fd = new FormData();
fd.append('image', file, file.name); // HD — no client-side compression
const urls = {
room: `/api/rooms/${id}/photo`,
cabinet: `/api/cabinets/${id}/photo`,
shelf: `/api/shelves/${id}/photo`,
book: `/api/books/${id}/photo`,
};
try {
const res = await req('POST', urls[type], fd, true);
const key = type==='book' ? 'image_filename' : 'photo_filename';
walkTree(n=>{ if(n.id===id) n[key]=res[key]; });
// Photo queue mode: process and advance without full re-render
if (_photoQueue && type === 'book') {
_photoQueue.processing = true;
renderPhotoQueue();
const book = findNode(id);
if (book && book.identification_status !== 'user_approved') {
try {
const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if (n.id === id) Object.assign(n, br); });
} catch { /* continue queue on process error */ }
}
_photoQueue.processing = false;
_photoQueue.index++;
renderPhotoQueue();
return;
}
render();
// Mobile: auto-queue AI after photo upload (books only)
if (!isDesktop()) {
if (type === 'book') {
const book = findNode(id);
if (book && book.identification_status !== 'user_approved') {
try {
const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if(n.id===id) Object.assign(n, br); });
toast(`Photo saved · Identified (${br.identification_status})`);
render();
} catch { toast('Photo saved'); }
} else { toast('Photo saved'); }
} else { toast('Photo saved'); }
} else { toast('Photo saved'); }
} catch(err) { toast('Upload failed: '+err.message); }
});