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.
This commit is contained in:
138
static/js/photo.js
Normal file
138
static/js/photo.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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); }
|
||||
});
|
||||
Reference in New Issue
Block a user