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:
night
2026-03-09 14:11:11 +03:00
commit f29678ebf1
64 changed files with 8605 additions and 0 deletions

166
static/js/detail-render.js Normal file
View File

@@ -0,0 +1,166 @@
/*
* detail-render.js
* HTML-string generators for the right-side detail panel (desktop) and
* the selected-entity view (mobile). Covers all four entity types.
*
* Depends on: S, _bnd (state.js); esc (helpers.js);
* pluginsByCategory, pluginsByTarget, vPluginBtn, getBookStats,
* vAiProgressBar, candidateSugRows, _STATUS_BADGE (tree-render.js);
* parseBounds, parseBndPluginResults (canvas-boundary.js)
* Provides: vDetailBody(), vRoomDetail(), vCabinetDetail(),
* vShelfDetail(), vBookDetail()
*/
// ── Room detail ──────────────────────────────────────────────────────────────
function vRoomDetail(r) {
const stats = getBookStats(r, 'room');
const totalBooks = stats.total;
return `<div>
${vAiProgressBar(stats)}
<p style="font-size:.72rem;color:#64748b">${r.cabinets.length} cabinet${r.cabinets.length!==1?'s':''} · ${totalBooks} book${totalBooks!==1?'s':''}</p>
</div>`;
}
// ── Detail body (right panel) ────────────────────────────────────────────────
function vDetailBody() {
if (!S.selected) return '<div class="det-empty">← Select a room, cabinet or shelf from the tree</div>';
const {type, id} = S.selected;
const node = findNode(id);
if (!node) return '<div class="det-empty">Not found</div>';
if (type === 'room') return vRoomDetail(node);
if (type === 'cabinet') return vCabinetDetail(node);
if (type === 'shelf') return vShelfDetail(node);
if (type === 'book') return vBookDetail(node);
return '';
}
// ── Cabinet detail ───────────────────────────────────────────────────────────
function vCabinetDetail(cab) {
const bounds = parseBounds(cab.shelf_boundaries);
const hasPhoto = !!cab.photo_filename;
const stats = getBookStats(cab, 'cabinet');
const bndPlugins = pluginsByTarget('boundary_detector', 'shelves');
const pluginResults = parseBndPluginResults(cab.ai_shelf_boundaries);
const pluginIds = Object.keys(pluginResults);
const sel = (_bnd?.nodeId === cab.id) ? _bnd.selectedPlugin
: (cab.shelves.length > 0 ? null : pluginIds[0] ?? null);
const selOpts = [
`<option value="">None</option>`,
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []),
].join('');
return `<div>
${vAiProgressBar(stats)}
${hasPhoto
? `<div class="img-wrap" id="bnd-wrap" data-type="cabinet" data-id="${cab.id}">
<img id="bnd-img" src="/images/${cab.photo_filename}?t=${Date.now()}" alt="">
<canvas id="bnd-canvas"></canvas>
</div>`
: `<div class="empty"><div class="ei">📷</div><div>Upload a cabinet photo (📷 in header) to get started</div></div>`}
${hasPhoto ? `<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>` : ''}
${hasPhoto ? `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${cab.shelves.length} shelf${cab.shelves.length!==1?'s':''} · ${bounds.length} boundar${bounds.length!==1?'ies':'y'}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map(p => vPluginBtn(p, cab.id, 'cabinets')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div>
</div>` : ''}
</div>`;
}
// ── Shelf detail ─────────────────────────────────────────────────────────────
function vShelfDetail(shelf) {
const bounds = parseBounds(shelf.book_boundaries);
const stats = getBookStats(shelf, 'shelf');
const bndPlugins = pluginsByTarget('boundary_detector', 'books');
const pluginResults = parseBndPluginResults(shelf.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults);
const sel = (_bnd?.nodeId === shelf.id) ? _bnd.selectedPlugin
: (shelf.books.length > 0 ? null : pluginIds[0] ?? null);
const selOpts = [
`<option value="">None</option>`,
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []),
].join('');
return `<div>
${vAiProgressBar(stats)}
<div class="img-wrap" id="bnd-wrap" data-type="shelf" data-id="${shelf.id}">
<img id="bnd-img" src="/api/shelves/${shelf.id}/image?t=${Date.now()}" alt=""
onerror="this.parentElement.innerHTML='<div class=empty style=padding:40px><div class=ei>📷</div><div>No image available — upload a cabinet photo first</div></div>'">
<canvas id="bnd-canvas"></canvas>
</div>
<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${shelf.books.length} book${shelf.books.length!==1?'s':''} · ${bounds.length} boundary${bounds.length!==1?'ies':''}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map(p => vPluginBtn(p, shelf.id, 'shelves')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div>
</div>
</div>`;
}
// ── Book detail ──────────────────────────────────────────────────────────────
function vBookDetail(b) {
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
const recognizers = pluginsByCategory('text_recognizer');
const identifiers = pluginsByCategory('book_identifier');
const searchers = pluginsByCategory('archive_searcher');
const hasRawText = !!(b.raw_text || '').trim();
return `<div class="book-panel">
<div>
<div class="book-img-label">Spine</div>
<div class="book-img-box"><img src="/api/books/${b.id}/spine?t=${Date.now()}" alt=""
onerror="this.style.display='none'"></div>
${b.image_filename
? `<div class="book-img-label">Title page</div>
<div class="book-img-box"><img src="/images/${b.image_filename}" alt=""></div>`
: ''}
</div>
<div>
<div class="card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span class="sbadge ${sc}" style="font-size:.7rem;padding:2px 7px">${sl}</span>
<span style="font-size:.72rem;color:#64748b">${b.identification_status ?? 'unidentified'}</span>
${b.analyzed_at ? `<span style="font-size:.68rem;color:#94a3b8;margin-left:auto">Identified ${b.analyzed_at.slice(0,10)}</span>` : ''}
</div>
<div class="fgroup">
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
Recognition
${recognizers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
${identifiers.map(p => vPluginBtn(p, b.id, 'books', !hasRawText)).join('')}
</label>
<textarea class="finput" id="d-raw-text" style="height:72px;font-family:monospace;font-size:.8rem" readonly>${esc(b.raw_text ?? '')}</textarea>
</div>
${searchers.length ? `<div class="fgroup">
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
Archives
${searchers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
</label>
</div>` : ''}
<div class="fgroup">
${candidateSugRows(b, 'title', 'd-title')}
<label class="flabel">Title</label>
<input class="finput" id="d-title" value="${esc(b.title ?? '')}"></div>
<div class="fgroup">
${candidateSugRows(b, 'author', 'd-author')}
<label class="flabel">Author</label>
<input class="finput" id="d-author" value="${esc(b.author ?? '')}"></div>
<div class="fgroup">
${candidateSugRows(b, 'year', 'd-year')}
<label class="flabel">Year</label>
<input class="finput" id="d-year" value="${esc(b.year ?? '')}" inputmode="numeric"></div>
<div class="fgroup">
${candidateSugRows(b, 'isbn', 'd-isbn')}
<label class="flabel">ISBN</label>
<input class="finput" id="d-isbn" value="${esc(b.isbn ?? '')}" inputmode="numeric"></div>
<div class="fgroup">
${candidateSugRows(b, 'publisher', 'd-pub')}
<label class="flabel">Publisher</label>
<input class="finput" id="d-pub" value="${esc(b.publisher ?? '')}"></div>
<div class="fgroup"><label class="flabel">Notes</label>
<textarea class="finput" id="d-notes">${esc(b.notes ?? '')}</textarea></div>
</div>
</div>
</div>`;
}