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:
166
static/js/detail-render.js
Normal file
166
static/js/detail-render.js
Normal 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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user