/*
* 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()
*/
/* exported vDetailBody, aiBlocksShown */
// ── Room detail ──────────────────────────────────────────────────────────────
function vRoomDetail(r) {
const stats = getBookStats(r, 'room');
const totalBooks = stats.total;
return `
${vAiProgressBar(stats)}
${r.cabinets.length} cabinet${r.cabinets.length !== 1 ? 's' : ''} · ${totalBooks} book${totalBooks !== 1 ? 's' : ''}
`;
}
// ── Root detail (no selection) ────────────────────────────────────────────────
function vAiLogEntry(entry) {
const ts = new Date(entry.ts * 1000).toLocaleTimeString();
const statusColor = entry.status === 'ok' ? '#15803d' : entry.status === 'error' ? '#dc2626' : '#b45309';
const statusLabel = entry.status === 'running' ? '⏳' : entry.status === 'ok' ? '✓' : '✗';
const dur = entry.duration_ms > 0 ? ` ${entry.duration_ms}ms` : '';
const model = entry.model
? `${esc(entry.model)}`
: '';
const isBook = entry.entity_type === 'books';
const entityLabel = isBook
? ``
: `${esc(entry.entity_id.slice(0, 8))}`;
const thumb = isBook
? `
`
: '';
return `
${statusLabel}
${esc(entry.plugin_id)} · ${entityLabel}${thumb}
${ts}${dur}
${model}
${entry.request ? `
Request: ${esc(entry.request)}
` : ''}
${entry.response ? `
Response: ${esc(entry.response)}
` : ''}
`;
}
function vRootDetail() {
const log = (_aiLog || []).slice().reverse(); // newest first
return `
AI Request Log
${
log.length === 0
? `
No AI requests yet. Use Identify or run a plugin on a book.
`
: log.map(vAiLogEntry).join('
')
}
`;
}
// ── Detail body (right panel) ────────────────────────────────────────────────
function vDetailBody() {
if (!S.selected) return `${vRootDetail()}
`;
const { type, id } = S.selected;
const node = findNode(id);
if (!node) return 'Not found
';
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 = [
``,
...pluginIds.map((pid) => ``),
...(pluginIds.length > 1 ? [``] : []),
].join('');
return `
${vAiProgressBar(stats)}
${
hasPhoto
? `
`
: `
📷
Upload a cabinet photo (📷 in header) to get started
`
}
${hasPhoto ? `
Drag lines · Ctrl+Alt+Click to add · Snap to AI guides
` : ''}
${
hasPhoto
? `
${bounds.length ? `
${cab.shelves.length} shelf${cab.shelves.length !== 1 ? 's' : ''} · ${bounds.length} boundar${bounds.length !== 1 ? 'ies' : 'y'}` : ''}
${bndPlugins.map((p) => vPluginBtn(p, cab.id, 'cabinets')).join('')}
`
: ''
}
`;
}
// ── 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 = [
``,
...pluginIds.map((pid) => ``),
...(pluginIds.length > 1 ? [``] : []),
].join('');
return `
${vAiProgressBar(stats)}
Drag lines · Ctrl+Alt+Click to add · Snap to AI guides
${bounds.length ? `
${shelf.books.length} book${shelf.books.length !== 1 ? 's' : ''} · ${bounds.length} boundary${bounds.length !== 1 ? 'ies' : ''}` : ''}
${bndPlugins.map((p) => vPluginBtn(p, shelf.id, 'shelves')).join('')}
`;
}
// ── AI blocks helpers ─────────────────────────────────────────────────────────
function parseAiBlocks(json) {
if (!json) return [];
try {
return JSON.parse(json) || [];
} catch {
return [];
}
}
function aiBlocksShown(b) {
if (b.id in _aiBlocksVisible) return _aiBlocksVisible[b.id];
return b.identification_status !== 'user_approved';
}
function vAiBlock(block, bookId) {
const score = typeof block.score === 'number' ? (block.score * 100).toFixed(0) + '%' : '';
const sources = (block.sources || []).join(', ');
const fields = [
['title', block.title],
['author', block.author],
['year', block.year],
['isbn', block.isbn],
['publisher', block.publisher],
].filter(([, v]) => v && v.trim());
const rows = fields
.map(
([k, v]) =>
`${k} ${esc(v)}
`,
)
.join('');
const blockData = esc(JSON.stringify(block));
return `
${score ? `${score}` : ''}
${sources ? `${esc(sources)}` : ''}
${rows}
`;
}
// ── Book detail ──────────────────────────────────────────────────────────────
function vBookDetail(b) {
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
const isLoading_ = isLoading('identify', b.id);
const blocks = parseAiBlocks(b.ai_blocks);
const shown = aiBlocksShown(b);
const spineUrl = `/api/books/${b.id}/spine?t=${Date.now()}`;
const titleUrl = b.image_filename ? `/images/${b.image_filename}` : '';
return `
Spine
${
titleUrl
? `
Title page
`
: ''
}
`;
}