Add per-request AI logging, DB batch queue, WS entity updates, and UI polish

- log_thread.py: thread-safe ContextVar bridge so executor threads can log
  individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
  entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
  run_batch_consumer() reads queue dynamically so new books can be added
  while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
  batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
  archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
  after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
  warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
  book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
  multi-file global architecture; all lint errors resolved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 12:10:54 +03:00
parent fd32be729f
commit b94f222c96
41 changed files with 2566 additions and 586 deletions

View File

@@ -1,29 +1,37 @@
/*
* layout.css
* Top-level layout: sticky header bar, two-column desktop layout
* (300px sidebar + flex main panel), mobile single-column default,
* Top-level layout: global header spanning full width, two-column desktop
* layout (300px sidebar + flex main panel), mobile single-column default,
* and the contenteditable header span used for inline entity renaming.
*
* Breakpoint: ≥768px = desktop two-column; <768px = mobile accordion.
*/
/* ── Header ── */
/* ── Page wrapper (header + content area) ── */
.page-wrap{display:flex;flex-direction:column;min-height:100vh}
/* ── Global header ── */
.hdr{background:#1e3a5f;color:white;padding:10px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:100;box-shadow:0 2px 6px rgba(0,0,0,.3);flex-shrink:0}
.hdr h1{flex:1;font-size:.96rem;font-weight:600}
.hdr h1{font-size:.96rem;font-weight:600}
.hbtn{background:none;border:none;color:white;min-width:34px;min-height:34px;border-radius:50%;cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.hbtn:active{background:rgba(255,255,255,.2)}
/* ── AI active indicator (in global header) ── */
.ai-indicator{display:inline-flex;align-items:center;gap:5px;font-size:.75rem;color:rgba(255,255,255,.9);padding:2px 8px;border-radius:10px;background:rgba(255,255,255,.12)}
.ai-dot{width:7px;height:7px;border-radius:50%;background:#f59e0b;animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.8)}}
/* ── Mobile layout (default) ── */
.layout{display:flex;flex-direction:column;min-height:100vh}
.layout{display:flex;flex-direction:column;flex:1}
.sidebar{flex:1}
.main-panel{display:none}
/* ── Desktop layout ── */
@media(min-width:768px){
body{overflow:hidden}
.layout{flex-direction:row;height:100vh;overflow:hidden}
.page-wrap{height:100vh;overflow:hidden}
.layout{flex-direction:row;flex:1;overflow:hidden}
.sidebar{width:300px;display:flex;flex-direction:column;border-right:1px solid #cbd5e1;overflow:hidden;flex-shrink:0}
.sidebar .hdr{padding:9px 12px}
.sidebar-body{flex:1;overflow-y:auto;padding:8px 10px 16px}
.main-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#e8eef5}
.main-hdr{background:#1e3a5f;color:white;padding:9px 14px;display:flex;align-items:center;gap:8px;flex-shrink:0}
@@ -31,6 +39,12 @@
.main-body{flex:1;overflow:auto;padding:14px}
}
/* ── Root detail panel ── */
.det-root{max-width:640px}
.ai-log-entry{border-bottom:1px solid #f1f5f9;padding:0 2px}
.ai-log-entry:last-child{border-bottom:none}
.ai-log-entry summary::-webkit-details-marker{display:none}
/* ── Detail header editable name ── */
.hdr-edit{display:block;outline:none;cursor:text;border-radius:3px;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hdr-edit:focus{background:rgba(255,255,255,.15);white-space:normal;overflow:visible}

View File

@@ -29,3 +29,10 @@
.pq-skip-btn{background:rgba(255,255,255,.1);color:#cbd5e1;border:none;border-radius:8px;padding:12px 18px;font-size:.85rem;cursor:pointer;min-width:70px}
.pq-skip-btn:active{background:rgba(255,255,255,.2)}
.pq-processing{position:absolute;inset:0;background:rgba(15,23,42,.88);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;font-size:.9rem}
/* ── Image popup ── */
.img-popup{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:500;align-items:center;justify-content:center}
.img-popup.open{display:flex}
.img-popup-inner{position:relative;max-width:90vw;max-height:90vh}
.img-popup-inner img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:4px;display:block}
.img-popup-close{position:absolute;top:-14px;right:-14px;background:#fff;border:none;border-radius:50%;width:28px;height:28px;cursor:pointer;font-size:18px;line-height:28px;text-align:center;padding:0;box-shadow:0 2px 6px rgba(0,0,0,.3)}

View File

@@ -33,6 +33,15 @@
<!-- Slide-in toast notification; text set by toast() in js/helpers.js -->
<div class="toast" id="toast"></div>
<!-- Full-screen image popup: shown when user clicks a book spine or title-page image.
Closed by clicking outside or the × button. -->
<div id="img-popup" class="img-popup">
<div class="img-popup-inner">
<button class="img-popup-close" id="img-popup-close">×</button>
<img id="img-popup-img" src="" alt="">
</div>
</div>
<!-- SortableJS: drag-and-drop reordering for rooms, cabinets, shelves, and books -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
@@ -73,7 +82,7 @@
with all action cases; accordion expand helpers. -->
<script src="js/events.js"></script>
<!-- render(), renderDetail(), loadConfig(), startBatchPolling(), loadTree(),
<!-- render(), renderDetail(), loadConfig(), connectBatchWs(), loadTree(),
and the bootstrap Promise.all([loadConfig(), loadTree()]) call. -->
<script src="js/init.js"></script>

View File

@@ -7,16 +7,22 @@
* Depends on: nothing
*/
/* exported req */
// ── API ──────────────────────────────────────────────────────────────────────
async function req(method, url, body = null, isForm = false) {
const opts = {method};
const opts = { method };
if (body) {
if (isForm) { opts.body = body; }
else { opts.headers = {'Content-Type':'application/json'}; opts.body = JSON.stringify(body); }
if (isForm) {
opts.body = body;
} else {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
}
const r = await fetch(url, opts);
if (!r.ok) {
const e = await r.json().catch(() => ({detail:'Request failed'}));
const e = await r.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(e.detail || 'Request failed');
}
return r.json();

View File

@@ -16,10 +16,16 @@
* setupDetailCanvas(), drawBnd(), clearSegHover()
*/
/* exported parseBounds, parseBndPluginResults, setupDetailCanvas, drawBnd */
// ── Boundary parsing helpers ─────────────────────────────────────────────────
function parseBounds(json) {
if (!json) return [];
try { return JSON.parse(json) || []; } catch { return []; }
try {
return JSON.parse(json) || [];
} catch {
return [];
}
}
function parseBndPluginResults(json) {
@@ -28,39 +34,57 @@ function parseBndPluginResults(json) {
const v = JSON.parse(json);
if (Array.isArray(v) || !v || typeof v !== 'object') return {};
return v;
} catch { return {}; }
} catch {
return {};
}
}
const SEG_FILLS = ['rgba(59,130,246,.14)','rgba(16,185,129,.14)','rgba(245,158,11,.14)','rgba(239,68,68,.14)','rgba(168,85,247,.14)'];
const SEG_STROKES = ['#3b82f6','#10b981','#f59e0b','#ef4444','#a855f7'];
const SEG_FILLS = [
'rgba(59,130,246,.14)',
'rgba(16,185,129,.14)',
'rgba(245,158,11,.14)',
'rgba(239,68,68,.14)',
'rgba(168,85,247,.14)',
];
const SEG_STROKES = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a855f7'];
// ── Canvas setup ─────────────────────────────────────────────────────────────
function setupDetailCanvas() {
const wrap = document.getElementById('bnd-wrap');
const img = document.getElementById('bnd-img');
const wrap = document.getElementById('bnd-wrap');
const img = document.getElementById('bnd-img');
const canvas = document.getElementById('bnd-canvas');
if (!wrap || !img || !canvas || !S.selected) return;
const {type, id} = S.selected;
const { type, id } = S.selected;
const node = findNode(id);
if (!node || (type !== 'cabinet' && type !== 'shelf')) return;
const axis = type === 'cabinet' ? 'y' : 'x';
const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries);
const axis = type === 'cabinet' ? 'y' : 'x';
const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries);
const pluginResults = parseBndPluginResults(type === 'cabinet' ? node.ai_shelf_boundaries : node.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults);
const segments = type === 'cabinet'
? node.shelves.map((s,i) => ({id:s.id, label:s.name||`Shelf ${i+1}`}))
: node.books.map((b,i) => ({id:b.id, label:b.title||`Book ${i+1}`}));
const pluginIds = Object.keys(pluginResults);
const segments =
type === 'cabinet'
? node.shelves.map((s, i) => ({ id: s.id, label: s.name || `Shelf ${i + 1}` }))
: node.books.map((b, i) => ({ id: b.id, label: b.title || `Book ${i + 1}` }));
const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0;
const prevSel = (_bnd?.nodeId === id) ? _bnd.selectedPlugin
: (hasChildren ? null : pluginIds[0] ?? null);
const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0;
const prevSel = _bnd?.nodeId === id ? _bnd.selectedPlugin : hasChildren ? null : (pluginIds[0] ?? null);
_bnd = {wrap, img, canvas, axis, boundaries:[...boundaries],
pluginResults, selectedPlugin: prevSel, segments, nodeId:id, nodeType:type};
_bnd = {
wrap,
img,
canvas,
axis,
boundaries: [...boundaries],
pluginResults,
selectedPlugin: prevSel,
segments,
nodeId: id,
nodeType: type,
};
function sizeAndDraw() {
canvas.width = img.offsetWidth;
canvas.width = img.offsetWidth;
canvas.height = img.offsetHeight;
drawBnd();
}
@@ -69,17 +93,18 @@ function setupDetailCanvas() {
canvas.addEventListener('pointerdown', bndPointerDown);
canvas.addEventListener('pointermove', bndPointerMove);
canvas.addEventListener('pointerup', bndPointerUp);
canvas.addEventListener('click', bndClick);
canvas.addEventListener('mousemove', bndHover);
canvas.addEventListener('mouseleave', () => clearSegHover());
canvas.addEventListener('pointerup', bndPointerUp);
canvas.addEventListener('click', bndClick);
canvas.addEventListener('mousemove', bndHover);
canvas.addEventListener('mouseleave', () => clearSegHover());
}
// ── Draw ─────────────────────────────────────────────────────────────────────
function drawBnd(dragIdx = -1, dragVal = null) {
if (!_bnd || S._cropMode) return;
const {canvas, axis, boundaries, segments} = _bnd;
const W = canvas.width, H = canvas.height;
const { canvas, axis, boundaries, segments } = _bnd;
const W = canvas.width,
H = canvas.height;
if (!W || !H) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
@@ -94,11 +119,12 @@ function drawBnd(dragIdx = -1, dragVal = null) {
// Draw segments
for (let i = 0; i < full.length - 1; i++) {
const a = full[i], b = full[i + 1];
const a = full[i],
b = full[i + 1];
const ci = i % SEG_FILLS.length;
ctx.fillStyle = SEG_FILLS[ci];
if (axis === 'y') ctx.fillRect(0, a*H, W, (b-a)*H);
else ctx.fillRect(a*W, 0, (b-a)*W, H);
if (axis === 'y') ctx.fillRect(0, a * H, W, (b - a) * H);
else ctx.fillRect(a * W, 0, (b - a) * W, H);
// Label
const seg = segments[i];
if (seg) {
@@ -106,10 +132,13 @@ function drawBnd(dragIdx = -1, dragVal = null) {
ctx.fillStyle = 'rgba(0,0,0,.5)';
const lbl = seg.label.slice(0, 24);
if (axis === 'y') {
ctx.fillText(lbl, 4, a*H + 14);
ctx.fillText(lbl, 4, a * H + 14);
} else {
ctx.save(); ctx.translate(a*W + 12, 14); ctx.rotate(Math.PI/2);
ctx.fillText(lbl, 0, 0); ctx.restore();
ctx.save();
ctx.translate(a * W + 12, 14);
ctx.rotate(Math.PI / 2);
ctx.fillText(lbl, 0, 0);
ctx.restore();
}
}
}
@@ -118,26 +147,36 @@ function drawBnd(dragIdx = -1, dragVal = null) {
ctx.setLineDash([5, 3]);
ctx.lineWidth = 2;
for (let i = 0; i < boundaries.length; i++) {
const val = (dragIdx === i && dragVal !== null) ? full[i+1] : boundaries[i];
const val = dragIdx === i && dragVal !== null ? full[i + 1] : boundaries[i];
ctx.strokeStyle = '#1e3a5f';
ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, val*H); ctx.lineTo(W, val*H); }
else { ctx.moveTo(val*W, 0); ctx.lineTo(val*W, H); }
if (axis === 'y') {
ctx.moveTo(0, val * H);
ctx.lineTo(W, val * H);
} else {
ctx.moveTo(val * W, 0);
ctx.lineTo(val * W, H);
}
ctx.stroke();
}
// Draw plugin boundary suggestions (dashed, non-interactive)
const {pluginResults, selectedPlugin} = _bnd;
const { pluginResults, selectedPlugin } = _bnd;
const pluginIds = Object.keys(pluginResults);
if (selectedPlugin && pluginIds.length) {
ctx.setLineDash([3, 6]);
ctx.lineWidth = 1.5;
const drawPluginBounds = (bounds, color) => {
ctx.strokeStyle = color;
for (const ab of (bounds || [])) {
for (const ab of bounds || []) {
ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, ab*H); ctx.lineTo(W, ab*H); }
else { ctx.moveTo(ab*W, 0); ctx.lineTo(ab*W, H); }
if (axis === 'y') {
ctx.moveTo(0, ab * H);
ctx.lineTo(W, ab * H);
} else {
ctx.moveTo(ab * W, 0);
ctx.lineTo(ab * W, H);
}
ctx.stroke();
}
};
@@ -151,46 +190,61 @@ function drawBnd(dragIdx = -1, dragVal = null) {
}
// ── Drag machinery ───────────────────────────────────────────────────────────
let _dragIdx = -1, _dragging = false;
let _dragIdx = -1,
_dragging = false;
function fracFromEvt(e) {
const r = _bnd.canvas.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
const y = (e.clientY - r.top) / r.height;
return _bnd.axis === 'y' ? y : x;
}
function nearestBnd(frac) {
const {boundaries, canvas, axis} = _bnd;
const { boundaries, canvas, axis } = _bnd;
const r = canvas.getBoundingClientRect();
const dim = axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim;
let best = -1, bestD = thresh;
boundaries.forEach((b,i) => { const d=Math.abs(b-frac); if(d<bestD){bestD=d;best=i;} });
let best = -1,
bestD = thresh;
boundaries.forEach((b, i) => {
const d = Math.abs(b - frac);
if (d < bestD) {
bestD = d;
best = i;
}
});
return best;
}
function snapToAi(frac) {
if (!_bnd?.selectedPlugin) return frac;
const {pluginResults, selectedPlugin} = _bnd;
const snapBounds = selectedPlugin === 'all'
? Object.values(pluginResults).flat()
: (pluginResults[selectedPlugin] || []);
const { pluginResults, selectedPlugin } = _bnd;
const snapBounds =
selectedPlugin === 'all' ? Object.values(pluginResults).flat() : pluginResults[selectedPlugin] || [];
if (!snapBounds.length) return frac;
const r = _bnd.canvas.getBoundingClientRect();
const dim = _bnd.axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim;
let best = frac, bestD = thresh;
snapBounds.forEach(ab => { const d = Math.abs(ab - frac); if (d < bestD) { bestD = d; best = ab; } });
let best = frac,
bestD = thresh;
snapBounds.forEach((ab) => {
const d = Math.abs(ab - frac);
if (d < bestD) {
bestD = d;
best = ab;
}
});
return best;
}
function bndPointerDown(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const idx = nearestBnd(frac);
const idx = nearestBnd(frac);
if (idx >= 0) {
_dragIdx = idx; _dragging = true;
_dragIdx = idx;
_dragging = true;
_bnd.canvas.setPointerCapture(e.pointerId);
e.stopPropagation();
}
@@ -200,8 +254,7 @@ function bndPointerMove(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const near = nearestBnd(frac);
_bnd.canvas.style.cursor = (near >= 0 || _dragging)
? (_bnd.axis==='y' ? 'ns-resize' : 'ew-resize') : 'default';
_bnd.canvas.style.cursor = near >= 0 || _dragging ? (_bnd.axis === 'y' ? 'ns-resize' : 'ew-resize') : 'default';
if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac);
}
@@ -209,22 +262,24 @@ async function bndPointerUp(e) {
if (!_dragging || !_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
_dragging = false;
const {boundaries, nodeId, nodeType} = _bnd;
const { boundaries, nodeId, nodeType } = _bnd;
const full = [0, ...boundaries, 1];
const clamped = Math.max(full[_dragIdx]+0.005, Math.min(full[_dragIdx+2]-0.005, frac));
const clamped = Math.max(full[_dragIdx] + 0.005, Math.min(full[_dragIdx + 2] - 0.005, frac));
boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000;
_bnd.boundaries = [...boundaries];
_dragIdx = -1;
drawBnd();
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try {
await req('PATCH', url, {boundaries});
await req('PATCH', url, { boundaries });
const node = findNode(nodeId);
if (node) {
if (nodeType==='cabinet') node.shelf_boundaries = JSON.stringify(boundaries);
if (nodeType === 'cabinet') node.shelf_boundaries = JSON.stringify(boundaries);
else node.book_boundaries = JSON.stringify(boundaries);
}
} catch(err) { toast('Save failed: ' + err.message); }
} catch (err) {
toast('Save failed: ' + err.message);
}
}
async function bndClick(e) {
@@ -232,40 +287,59 @@ async function bndClick(e) {
if (!e.ctrlKey || !e.altKey) return;
e.preventDefault();
const frac = snapToAi(fracFromEvt(e));
const {boundaries, nodeId, nodeType} = _bnd;
const newBounds = [...boundaries, frac].sort((a,b)=>a-b);
const { boundaries, nodeId, nodeType } = _bnd;
const newBounds = [...boundaries, frac].sort((a, b) => a - b);
_bnd.boundaries = newBounds;
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try {
await req('PATCH', url, {boundaries: newBounds});
await req('PATCH', url, { boundaries: newBounds });
if (nodeType === 'cabinet') {
const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null);
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===nodeId){
c.shelf_boundaries=JSON.stringify(newBounds); c.shelves.push({...s,books:[]});
}}));
S.tree.forEach((r) =>
r.cabinets.forEach((c) => {
if (c.id === nodeId) {
c.shelf_boundaries = JSON.stringify(newBounds);
c.shelves.push({ ...s, books: [] });
}
}),
);
} else {
const b = await req('POST', `/api/shelves/${nodeId}/books`);
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===nodeId){
s.book_boundaries=JSON.stringify(newBounds); s.books.push(b);
}})));
S.tree.forEach((r) =>
r.cabinets.forEach((c) =>
c.shelves.forEach((s) => {
if (s.id === nodeId) {
s.book_boundaries = JSON.stringify(newBounds);
s.books.push(b);
}
}),
),
);
}
render();
} catch(err) { toast('Error: ' + err.message); }
} catch (err) {
toast('Error: ' + err.message);
}
}
function bndHover(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const {boundaries, segments} = _bnd;
const { boundaries, segments } = _bnd;
const full = [0, ...boundaries, 1];
let segIdx = -1;
for (let i = 0; i < full.length-1; i++) { if(frac>=full[i]&&frac<full[i+1]){segIdx=i;break;} }
for (let i = 0; i < full.length - 1; i++) {
if (frac >= full[i] && frac < full[i + 1]) {
segIdx = i;
break;
}
}
clearSegHover();
if (segIdx>=0 && segments[segIdx]) {
if (segIdx >= 0 && segments[segIdx]) {
document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover');
}
}
function clearSegHover() {
document.querySelectorAll('.seg-hover').forEach(el=>el.classList.remove('seg-hover'));
document.querySelectorAll('.seg-hover').forEach((el) => el.classList.remove('seg-hover'));
}

View File

@@ -13,28 +13,31 @@
* Provides: startCropMode(), cancelCrop(), confirmCrop()
*/
/* exported startCropMode */
// ── Crop state ───────────────────────────────────────────────────────────────
let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode
let _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null
let _cropDragStart = null; // {fx,fy,x1,y1,x2,y2} snapshot at drag start
let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode
let _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null
let _cropDragStart = null; // {fx,fy,x1,y1,x2,y2} snapshot at drag start
// ── Public entry point ───────────────────────────────────────────────────────
function startCropMode(type, id) {
const canvas = document.getElementById('bnd-canvas');
const wrap = document.getElementById('bnd-wrap');
const wrap = document.getElementById('bnd-wrap');
if (!canvas || !wrap) return;
S._cropMode = {type, id};
_cropState = {x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95};
S._cropMode = { type, id };
_cropState = { x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95 };
canvas.addEventListener('pointerdown', cropPointerDown);
canvas.addEventListener('pointermove', cropPointerMove);
canvas.addEventListener('pointerup', cropPointerUp);
canvas.addEventListener('pointerup', cropPointerUp);
document.getElementById('crop-bar')?.remove();
const bar = document.createElement('div');
bar.id = 'crop-bar';
bar.style.cssText = 'margin-top:10px;display:flex;gap:8px';
bar.innerHTML = '<button class="btn btn-p" id="crop-ok">Confirm crop</button><button class="btn btn-s" id="crop-cancel">Cancel</button>';
bar.innerHTML =
'<button class="btn btn-p" id="crop-ok">Confirm crop</button><button class="btn btn-s" id="crop-cancel">Cancel</button>';
wrap.after(bar);
document.getElementById('crop-ok').addEventListener('click', confirmCrop);
document.getElementById('crop-cancel').addEventListener('click', cancelCrop);
@@ -47,63 +50,81 @@ function drawCropOverlay() {
const canvas = document.getElementById('bnd-canvas');
if (!canvas || !_cropState) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const {x1, y1, x2, y2} = _cropState;
const px1=x1*W, py1=y1*H, px2=x2*W, py2=y2*H;
const W = canvas.width,
H = canvas.height;
const { x1, y1, x2, y2 } = _cropState;
const px1 = x1 * W,
py1 = y1 * H,
px2 = x2 * W,
py2 = y2 * H;
ctx.clearRect(0, 0, W, H);
// Dark shadow outside crop rect
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(0, 0, W, H);
ctx.clearRect(px1, py1, px2-px1, py2-py1);
ctx.clearRect(px1, py1, px2 - px1, py2 - py1);
// Bright border
ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([]);
ctx.strokeRect(px1, py1, px2-px1, py2-py1);
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.strokeRect(px1, py1, px2 - px1, py2 - py1);
// Corner handles
const hs = 9;
ctx.fillStyle = '#38bdf8';
[[px1,py1],[px2,py1],[px1,py2],[px2,py2]].forEach(([x,y]) => ctx.fillRect(x-hs/2, y-hs/2, hs, hs));
[
[px1, py1],
[px2, py1],
[px1, py2],
[px2, py2],
].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs));
}
// ── Hit testing ──────────────────────────────────────────────────────────────
function _cropFracFromEvt(e) {
const canvas = document.getElementById('bnd-canvas');
const r = canvas.getBoundingClientRect();
return {fx: (e.clientX-r.left)/r.width, fy: (e.clientY-r.top)/r.height};
return { fx: (e.clientX - r.left) / r.width, fy: (e.clientY - r.top) / r.height };
}
function _getCropPart(fx, fy) {
if (!_cropState) return null;
const {x1, y1, x2, y2} = _cropState;
const { x1, y1, x2, y2 } = _cropState;
const th = 0.05;
const inX=fx>=x1&&fx<=x2, inY=fy>=y1&&fy<=y2;
const nX1=Math.abs(fx-x1)<th, nX2=Math.abs(fx-x2)<th;
const nY1=Math.abs(fy-y1)<th, nY2=Math.abs(fy-y2)<th;
if (nX1&&nY1) return 'tl'; if (nX2&&nY1) return 'tr';
if (nX1&&nY2) return 'bl'; if (nX2&&nY2) return 'br';
if (nY1&&inX) return 't'; if (nY2&&inX) return 'b';
if (nX1&&inY) return 'l'; if (nX2&&inY) return 'r';
if (inX&&inY) return 'move';
const inX = fx >= x1 && fx <= x2,
inY = fy >= y1 && fy <= y2;
const nX1 = Math.abs(fx - x1) < th,
nX2 = Math.abs(fx - x2) < th;
const nY1 = Math.abs(fy - y1) < th,
nY2 = Math.abs(fy - y2) < th;
if (nX1 && nY1) return 'tl';
if (nX2 && nY1) return 'tr';
if (nX1 && nY2) return 'bl';
if (nX2 && nY2) return 'br';
if (nY1 && inX) return 't';
if (nY2 && inX) return 'b';
if (nX1 && inY) return 'l';
if (nX2 && inY) return 'r';
if (inX && inY) return 'move';
return null;
}
function _cropPartCursor(part) {
if (!part) return 'crosshair';
if (part==='move') return 'move';
if (part==='tl'||part==='br') return 'nwse-resize';
if (part==='tr'||part==='bl') return 'nesw-resize';
if (part==='t'||part==='b') return 'ns-resize';
if (part === 'move') return 'move';
if (part === 'tl' || part === 'br') return 'nwse-resize';
if (part === 'tr' || part === 'bl') return 'nesw-resize';
if (part === 't' || part === 'b') return 'ns-resize';
return 'ew-resize';
}
// ── Pointer events ───────────────────────────────────────────────────────────
function cropPointerDown(e) {
if (!_cropState) return;
const {fx, fy} = _cropFracFromEvt(e);
const { fx, fy } = _cropFracFromEvt(e);
const part = _getCropPart(fx, fy);
if (part) {
_cropDragPart = part;
_cropDragStart = {fx, fy, ..._cropState};
_cropDragStart = { fx, fy, ..._cropState };
document.getElementById('bnd-canvas').setPointerCapture(e.pointerId);
}
}
@@ -111,19 +132,23 @@ function cropPointerDown(e) {
function cropPointerMove(e) {
if (!_cropState) return;
const canvas = document.getElementById('bnd-canvas');
const {fx, fy} = _cropFracFromEvt(e);
const { fx, fy } = _cropFracFromEvt(e);
if (_cropDragPart && _cropDragStart) {
const dx=fx-_cropDragStart.fx, dy=fy-_cropDragStart.fy;
const s = {..._cropState};
if (_cropDragPart==='move') {
const w=_cropDragStart.x2-_cropDragStart.x1, h=_cropDragStart.y2-_cropDragStart.y1;
s.x1=Math.max(0,Math.min(1-w,_cropDragStart.x1+dx)); s.y1=Math.max(0,Math.min(1-h,_cropDragStart.y1+dy));
s.x2=s.x1+w; s.y2=s.y1+h;
const dx = fx - _cropDragStart.fx,
dy = fy - _cropDragStart.fy;
const s = { ..._cropState };
if (_cropDragPart === 'move') {
const w = _cropDragStart.x2 - _cropDragStart.x1,
h = _cropDragStart.y2 - _cropDragStart.y1;
s.x1 = Math.max(0, Math.min(1 - w, _cropDragStart.x1 + dx));
s.y1 = Math.max(0, Math.min(1 - h, _cropDragStart.y1 + dy));
s.x2 = s.x1 + w;
s.y2 = s.y1 + h;
} else {
if (_cropDragPart.includes('l')) s.x1=Math.max(0,Math.min(_cropDragStart.x2-0.05,_cropDragStart.x1+dx));
if (_cropDragPart.includes('r')) s.x2=Math.min(1,Math.max(_cropDragStart.x1+0.05,_cropDragStart.x2+dx));
if (_cropDragPart.includes('t')) s.y1=Math.max(0,Math.min(_cropDragStart.y2-0.05,_cropDragStart.y1+dy));
if (_cropDragPart.includes('b')) s.y2=Math.min(1,Math.max(_cropDragStart.y1+0.05,_cropDragStart.y2+dy));
if (_cropDragPart.includes('l')) s.x1 = Math.max(0, Math.min(_cropDragStart.x2 - 0.05, _cropDragStart.x1 + dx));
if (_cropDragPart.includes('r')) s.x2 = Math.min(1, Math.max(_cropDragStart.x1 + 0.05, _cropDragStart.x2 + dx));
if (_cropDragPart.includes('t')) s.y1 = Math.max(0, Math.min(_cropDragStart.y2 - 0.05, _cropDragStart.y1 + dy));
if (_cropDragPart.includes('b')) s.y2 = Math.min(1, Math.max(_cropDragStart.y1 + 0.05, _cropDragStart.y2 + dy));
}
_cropState = s;
drawCropOverlay();
@@ -133,34 +158,53 @@ function cropPointerMove(e) {
}
}
function cropPointerUp() { _cropDragPart = null; _cropDragStart = null; }
function cropPointerUp() {
_cropDragPart = null;
_cropDragStart = null;
}
// ── Confirm / cancel ─────────────────────────────────────────────────────────
async function confirmCrop() {
if (!_cropState || !S._cropMode) return;
const img = document.getElementById('bnd-img');
if (!img) return;
const {x1, y1, x2, y2} = _cropState;
const W=img.naturalWidth, H=img.naturalHeight;
const px = {x:Math.round(x1*W), y:Math.round(y1*H), w:Math.round((x2-x1)*W), h:Math.round((y2-y1)*H)};
if (px.w<10||px.h<10) { toast('Selection too small'); return; }
const {type, id} = S._cropMode;
const url = type==='cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`;
const { x1, y1, x2, y2 } = _cropState;
const W = img.naturalWidth,
H = img.naturalHeight;
const px = {
x: Math.round(x1 * W),
y: Math.round(y1 * H),
w: Math.round((x2 - x1) * W),
h: Math.round((y2 - y1) * H),
};
if (px.w < 10 || px.h < 10) {
toast('Selection too small');
return;
}
const { type, id } = S._cropMode;
const url = type === 'cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`;
try {
await req('POST', url, px);
toast('Cropped'); cancelCrop(); render();
} catch(err) { toast('Crop failed: '+err.message); }
toast('Cropped');
cancelCrop();
render();
} catch (err) {
toast('Crop failed: ' + err.message);
}
}
function cancelCrop() {
S._cropMode = null; _cropState = null; _cropDragPart = null; _cropDragStart = null;
S._cropMode = null;
_cropState = null;
_cropDragPart = null;
_cropDragStart = null;
document.getElementById('crop-bar')?.remove();
const canvas = document.getElementById('bnd-canvas');
if (canvas) {
canvas.removeEventListener('pointerdown', cropPointerDown);
canvas.removeEventListener('pointermove', cropPointerMove);
canvas.removeEventListener('pointerup', cropPointerUp);
canvas.removeEventListener('pointerup', cropPointerUp);
canvas.style.cursor = '';
}
drawBnd(); // restore boundary overlay
drawBnd(); // restore boundary overlay
}

View File

@@ -11,26 +11,76 @@
* vShelfDetail(), vBookDetail()
*/
/* exported vDetailBody, aiBlocksShown */
// ── 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>
<p style="font-size:.72rem;color:#64748b">${r.cabinets.length} cabinet${r.cabinets.length !== 1 ? 's' : ''} · ${totalBooks} book${totalBooks !== 1 ? 's' : ''}</p>
</div>`;
}
// ── 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
? `<span style="font-size:.68rem;color:#94a3b8;margin-left:6px">${esc(entry.model)}</span>`
: '';
const isBook = entry.entity_type === 'books';
const entityLabel = isBook
? `<button data-a="select" data-type="book" data-id="${esc(entry.entity_id)}"
style="background:none;border:none;padding:0;cursor:pointer;color:#2563eb;font-size:.75rem;text-decoration:underline"
>${esc(entry.entity_id.slice(0, 8))}</button>`
: `<span>${esc(entry.entity_id.slice(0, 8))}</span>`;
const thumb = isBook
? `<img src="/api/books/${esc(entry.entity_id)}/spine" alt=""
style="height:30px;width:auto;vertical-align:middle;border-radius:2px;margin-left:2px"
onerror="this.style.display='none'">`
: '';
return `<details class="ai-log-entry">
<summary style="display:flex;align-items:center;gap:6px;cursor:pointer;list-style:none;padding:6px 0">
<span style="color:${statusColor};font-weight:600;font-size:.78rem;width:1.2rem;text-align:center">${statusLabel}</span>
<span style="font-size:.75rem;color:#475569;flex:1;display:flex;align-items:center;gap:4px;flex-wrap:wrap">
${esc(entry.plugin_id)} · ${entityLabel}${thumb}
</span>
<span style="font-size:.68rem;color:#94a3b8;white-space:nowrap">${ts}${dur}</span>
</summary>
<div style="padding:6px 0 6px 1.8rem;font-size:.75rem;color:#475569">
${model}
${entry.request ? `<div style="margin-top:4px;color:#64748b"><strong>Request:</strong> ${esc(entry.request)}</div>` : ''}
${entry.response ? `<div style="margin-top:4px;color:#64748b"><strong>Response:</strong> ${esc(entry.response)}</div>` : ''}
</div>
</details>`;
}
function vRootDetail() {
const log = (_aiLog || []).slice().reverse(); // newest first
return `<div style="padding:0">
<div style="font-size:.72rem;font-weight:600;color:#64748b;margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">AI Request Log</div>
${
log.length === 0
? `<div style="font-size:.78rem;color:#94a3b8">No AI requests yet. Use Identify or run a plugin on a book.</div>`
: log.map(vAiLogEntry).join('<hr style="border:none;border-top:1px solid #f1f5f9;margin:0">')
}
</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;
if (!S.selected) return `<div class="det-root">${vRootDetail()}</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 === 'room') return vRoomDetail(node);
if (type === 'cabinet') return vCabinetDetail(node);
if (type === 'shelf') return vShelfDetail(node);
if (type === 'book') return vBookDetail(node);
if (type === 'shelf') return vShelfDetail(node);
if (type === 'book') return vBookDetail(node);
return '';
}
@@ -42,29 +92,34 @@ function vCabinetDetail(cab) {
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 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>`] : []),
...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}">
${
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>`}
: `<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>` : ''}
${
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('')}
${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>`
: ''
}
</div>`;
}
@@ -75,12 +130,11 @@ function vShelfDetail(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 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>`] : []),
...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)}
@@ -91,72 +145,115 @@ function vShelfDetail(shelf) {
</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>` : ''}
${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('')}
${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>`;
}
// ── 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]) =>
`<div style="font-size:.78rem;color:#475569"><span style="color:#94a3b8;min-width:4.5rem;display:inline-block">${k}</span> ${esc(v)}</div>`,
)
.join('');
const blockData = esc(JSON.stringify(block));
return `<div class="ai-block" data-a="apply-ai-block" data-id="${bookId}" data-block="${blockData}"
style="cursor:pointer;border:1px solid #e2e8f0;border-radius:6px;padding:8px 10px;margin-bottom:6px;background:#f8fafc">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap">
${score ? `<span style="background:#dbeafe;color:#1e40af;border-radius:4px;padding:1px 6px;font-size:.72rem;font-weight:600">${score}</span>` : ''}
${sources ? `<span style="font-size:.7rem;color:#64748b">${esc(sources)}</span>` : ''}
</div>
${rows}
</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();
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 `<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 class="book-img-box">
<img src="${spineUrl}" alt="" style="cursor:pointer"
data-a="open-img-popup" data-src="${spineUrl}"
onerror="this.style.display='none'">
</div>
${
titleUrl
? `<div class="book-img-label">Title page</div>
<div class="book-img-box">
<img src="${titleUrl}" alt="" style="cursor:pointer"
data-a="open-img-popup" data-src="${titleUrl}">
</div>`
: ''
}
</div>
<div>
<div class="card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<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>` : ''}
${b.analyzed_at ? `<span style="font-size:.68rem;color:#94a3b8">Identified ${b.analyzed_at.slice(0, 10)}</span>` : ''}
<button class="btn btn-s" style="padding:2px 10px;font-size:.78rem;min-height:0;margin-left:auto"
data-a="identify-book" data-id="${b.id}"${isLoading_ ? ' disabled' : ''}>
${isLoading_ ? '⏳ Identifying…' : '🔍 Identify'}
</button>
</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>
${
blocks.length
? `<div style="margin-bottom:8px">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
<span style="font-size:.72rem;font-weight:600;color:#475569">AI Results (${blocks.length})</span>
<button class="btn btn-s" style="padding:1px 7px;font-size:.72rem;min-height:0;margin-left:auto"
data-a="toggle-ai-blocks" data-id="${b.id}">${shown ? 'Hide' : 'Show'}</button>
</div>
${shown ? blocks.map((bl) => vAiBlock(bl, b.id)).join('') : ''}
</div>`
: ''
}
<div class="fgroup"><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>
<div class="fgroup"><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>
<div class="fgroup"><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>
<div class="fgroup"><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>
<div class="fgroup"><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>

View File

@@ -12,54 +12,84 @@
* Provides: attachEditables(), initSortables()
*/
/* exported attachEditables, initSortables */
// ── SortableJS instances (destroyed and recreated on each render) ─────────────
let _sortables = [];
// ── Inline name editing ──────────────────────────────────────────────────────
function attachEditables() {
document.querySelectorAll('[contenteditable=true]').forEach(el => {
document.querySelectorAll('[contenteditable=true]').forEach((el) => {
el.dataset.orig = el.textContent.trim();
el.addEventListener('keydown', e => {
if (e.key==='Enter') { e.preventDefault(); el.blur(); }
if (e.key==='Escape') { el.textContent=el.dataset.orig; el.blur(); }
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
el.blur();
}
if (e.key === 'Escape') {
el.textContent = el.dataset.orig;
el.blur();
}
e.stopPropagation();
});
el.addEventListener('blur', async () => {
const val = el.textContent.trim();
if (!val||val===el.dataset.orig) { if(!val) el.textContent=el.dataset.orig; return; }
const newName = val.replace(/^[🏠📚]\s*/u,'').trim();
const {type, id} = el.dataset;
const url = {room:`/api/rooms/${id}`,cabinet:`/api/cabinets/${id}`,shelf:`/api/shelves/${id}`}[type];
if (!val || val === el.dataset.orig) {
if (!val) el.textContent = el.dataset.orig;
return;
}
const newName = val.replace(/^[🏠📚]\s*/u, '').trim();
const { type, id } = el.dataset;
const url = { room: `/api/rooms/${id}`, cabinet: `/api/cabinets/${id}`, shelf: `/api/shelves/${id}` }[type];
if (!url) return;
try {
await req('PUT', url, {name: newName});
await req('PUT', url, { name: newName });
el.dataset.orig = el.textContent.trim();
walkTree(n=>{ if(n.id===id) n.name=newName; });
walkTree((n) => {
if (n.id === id) n.name = newName;
});
// Update sidebar label if editing from header (sidebar has non-editable nname spans)
const sideLabel = document.querySelector(`.node[data-id="${id}"] .nname`);
if (sideLabel && sideLabel !== el) {
const prefix = type==='room' ? '🏠 ' : type==='cabinet' ? '📚 ' : '';
const prefix = type === 'room' ? '🏠 ' : type === 'cabinet' ? '📚 ' : '';
sideLabel.textContent = prefix + newName;
}
} catch(err) { el.textContent=el.dataset.orig; toast('Rename failed: '+err.message); }
} catch (err) {
el.textContent = el.dataset.orig;
toast('Rename failed: ' + err.message);
}
});
el.addEventListener('click', e=>e.stopPropagation());
el.addEventListener('click', (e) => e.stopPropagation());
});
}
// ── SortableJS drag-and-drop ─────────────────────────────────────────────────
function initSortables() {
_sortables.forEach(s=>{ try{s.destroy();}catch(_){} });
_sortables.forEach((s) => {
try {
s.destroy();
} catch (_) {
// ignore destroy errors on stale instances
}
});
_sortables = [];
document.querySelectorAll('.sortable-list').forEach(el => {
document.querySelectorAll('.sortable-list').forEach((el) => {
const type = el.dataset.type;
_sortables.push(Sortable.create(el, {
handle:'.drag-h', animation:120, ghostClass:'drag-ghost',
onEnd: async () => {
const ids = [...el.querySelectorAll(':scope > .node')].map(n=>n.dataset.id);
try { await req('PATCH',`/api/${type}/reorder`,{ids}); }
catch(err) { toast('Reorder failed'); await loadTree(); }
},
}));
_sortables.push(
Sortable.create(el, {
handle: '.drag-h',
animation: 120,
ghostClass: 'drag-ghost',
onEnd: async () => {
const ids = [...el.querySelectorAll(':scope > .node')].map((n) => n.dataset.id);
try {
await req('PATCH', `/api/${type}/reorder`, { ids });
} catch (_err) {
toast('Reorder failed');
await loadTree();
}
},
}),
);
});
}

View File

@@ -12,7 +12,7 @@
* Depends on: S, _bnd, _batchState, _photoQueue (state.js);
* req (api.js); toast, isDesktop (helpers.js);
* walkTree, removeNode, findNode, parseBounds (tree-render.js /
* canvas-boundary.js); render, renderDetail, startBatchPolling
* canvas-boundary.js); render, renderDetail, connectBatchWs
* (init.js); startCropMode (canvas-crop.js);
* triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js);
* drawBnd (canvas-boundary.js)
@@ -22,53 +22,61 @@
// ── Accordion helpers ────────────────────────────────────────────────────────
function getSiblingIds(id, type) {
if (!S.tree) return [];
if (type === 'room') return S.tree.filter(r => r.id !== id).map(r => r.id);
if (type === 'room') return S.tree.filter((r) => r.id !== id).map((r) => r.id);
for (const r of S.tree) {
if (type === 'cabinet' && r.cabinets.some(c => c.id === id))
return r.cabinets.filter(c => c.id !== id).map(c => c.id);
if (type === 'cabinet' && r.cabinets.some((c) => c.id === id))
return r.cabinets.filter((c) => c.id !== id).map((c) => c.id);
for (const c of r.cabinets) {
if (type === 'shelf' && c.shelves.some(s => s.id === id))
return c.shelves.filter(s => s.id !== id).map(s => s.id);
if (type === 'shelf' && c.shelves.some((s) => s.id === id))
return c.shelves.filter((s) => s.id !== id).map((s) => s.id);
}
}
return [];
}
function accordionExpand(id, type) {
if (!isDesktop()) getSiblingIds(id, type).forEach(sid => S.expanded.delete(sid));
if (!isDesktop()) getSiblingIds(id, type).forEach((sid) => S.expanded.delete(sid));
S.expanded.add(id);
}
// ── Event delegation ─────────────────────────────────────────────────────────
document.getElementById('app').addEventListener('click', async e => {
document.getElementById('app').addEventListener('click', async (e) => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
try { await handle(d.a, d, e); }
catch(err) { toast('Error: '+err.message); }
try {
await handle(d.a, d, e);
} catch (err) {
toast('Error: ' + err.message);
}
});
document.getElementById('app').addEventListener('change', async e => {
document.getElementById('app').addEventListener('change', async (e) => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
try { await handle(d.a, d, e); }
catch(err) { toast('Error: '+err.message); }
try {
await handle(d.a, d, e);
} catch (err) {
toast('Error: ' + err.message);
}
});
// Photo queue overlay is outside #app so needs its own listener
document.getElementById('photo-queue-overlay').addEventListener('click', async e => {
document.getElementById('photo-queue-overlay').addEventListener('click', async (e) => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
try { await handle(d.a, d, e); }
catch(err) { toast('Error: ' + err.message); }
try {
await handle(d.a, d, e);
} catch (err) {
toast('Error: ' + err.message);
}
});
// ── Action dispatcher ────────────────────────────────────────────────────────
async function handle(action, d, e) {
switch (action) {
case 'select': {
// Ignore if the click hit a button or editable inside the row
if (e?.target?.closest('button,[contenteditable]')) return;
@@ -80,14 +88,16 @@ async function handle(action, d, e) {
}
break;
}
S.selected = {type: d.type, id: d.id};
S.selected = { type: d.type, id: d.id };
S._loading = {};
render(); break;
render();
break;
}
case 'deselect': {
S.selected = null;
render(); break;
render();
break;
}
case 'toggle': {
@@ -95,168 +105,329 @@ async function handle(action, d, e) {
// Mobile: expand-only (no collapse to avoid accidental mistaps)
accordionExpand(d.id, d.type);
} else {
if (S.expanded.has(d.id)) { S.expanded.delete(d.id); }
else { S.expanded.add(d.id); }
if (S.expanded.has(d.id)) {
S.expanded.delete(d.id);
} else {
S.expanded.add(d.id);
}
}
render(); break;
render();
break;
}
// Rooms
case 'add-room': {
const r = await req('POST','/api/rooms');
if (!S.tree) S.tree=[];
S.tree.push({...r, cabinets:[]});
S.expanded.add(r.id); render(); break;
const r = await req('POST', '/api/rooms');
if (!S.tree) S.tree = [];
S.tree.push({ ...r, cabinets: [] });
S.expanded.add(r.id);
render();
break;
}
case 'del-room': {
if (!confirm('Delete room and all contents?')) break;
await req('DELETE',`/api/rooms/${d.id}`);
removeNode('room',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
await req('DELETE', `/api/rooms/${d.id}`);
removeNode('room', d.id);
if (S.selected?.id === d.id) S.selected = null;
render();
break;
}
// Cabinets
case 'add-cabinet': {
const c = await req('POST',`/api/rooms/${d.id}/cabinets`);
S.tree.forEach(r=>{ if(r.id===d.id) r.cabinets.push({...c,shelves:[]}); });
S.expanded.add(d.id); render(); break; // expand parent room
const c = await req('POST', `/api/rooms/${d.id}/cabinets`);
S.tree.forEach((r) => {
if (r.id === d.id) r.cabinets.push({ ...c, shelves: [] });
});
S.expanded.add(d.id);
render();
break; // expand parent room
}
case 'del-cabinet': {
if (!confirm('Delete cabinet and all contents?')) break;
await req('DELETE',`/api/cabinets/${d.id}`);
removeNode('cabinet',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
await req('DELETE', `/api/cabinets/${d.id}`);
removeNode('cabinet', d.id);
if (S.selected?.id === d.id) S.selected = null;
render();
break;
}
// Shelves
case 'add-shelf': {
const cab = findNode(d.id);
const prevCount = cab ? cab.shelves.length : 0;
const s = await req('POST',`/api/cabinets/${d.id}/shelves`);
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelves.push({...s,books:[]}); }));
const s = await req('POST', `/api/cabinets/${d.id}/shelves`);
S.tree.forEach((r) =>
r.cabinets.forEach((c) => {
if (c.id === d.id) c.shelves.push({ ...s, books: [] });
}),
);
if (prevCount > 0) {
// Split last segment in half to make room for new shelf
const bounds = parseBounds(cab.shelf_boundaries);
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0;
const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000;
const newBounds = [...bounds, newBound];
await req('PATCH', `/api/cabinets/${d.id}/boundaries`, {boundaries: newBounds});
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelf_boundaries=JSON.stringify(newBounds); }));
await req('PATCH', `/api/cabinets/${d.id}/boundaries`, { boundaries: newBounds });
S.tree.forEach((r) =>
r.cabinets.forEach((c) => {
if (c.id === d.id) c.shelf_boundaries = JSON.stringify(newBounds);
}),
);
}
S.expanded.add(d.id); render(); break; // expand parent cabinet
S.expanded.add(d.id);
render();
break; // expand parent cabinet
}
case 'del-shelf': {
if (!confirm('Delete shelf and all books?')) break;
await req('DELETE',`/api/shelves/${d.id}`);
removeNode('shelf',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
await req('DELETE', `/api/shelves/${d.id}`);
removeNode('shelf', d.id);
if (S.selected?.id === d.id) S.selected = null;
render();
break;
}
// Books
case 'add-book': {
const shelf = findNode(d.id);
const prevCount = shelf ? shelf.books.length : 0;
const b = await req('POST',`/api/shelves/${d.id}/books`);
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.books.push(b); })));
const b = await req('POST', `/api/shelves/${d.id}/books`);
S.tree.forEach((r) =>
r.cabinets.forEach((c) =>
c.shelves.forEach((s) => {
if (s.id === d.id) s.books.push(b);
}),
),
);
if (prevCount > 0) {
// Split last segment in half to make room for new book
const bounds = parseBounds(shelf.book_boundaries);
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0;
const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000;
const newBounds = [...bounds, newBound];
await req('PATCH', `/api/shelves/${d.id}/boundaries`, {boundaries: newBounds});
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.book_boundaries=JSON.stringify(newBounds); })));
await req('PATCH', `/api/shelves/${d.id}/boundaries`, { boundaries: newBounds });
S.tree.forEach((r) =>
r.cabinets.forEach((c) =>
c.shelves.forEach((s) => {
if (s.id === d.id) s.book_boundaries = JSON.stringify(newBounds);
}),
),
);
}
S.expanded.add(d.id); render(); break; // expand parent shelf
S.expanded.add(d.id);
render();
break; // expand parent shelf
}
case 'del-book': {
if (!confirm('Delete this book?')) break;
await req('DELETE',`/api/books/${d.id}`);
removeNode('book',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
await req('DELETE', `/api/books/${d.id}`);
removeNode('book', d.id);
if (S.selected?.id === d.id) S.selected = null;
render();
break;
}
case 'del-book-confirm': {
if (!confirm('Delete this book?')) break;
await req('DELETE',`/api/books/${d.id}`);
removeNode('book',d.id);
S.selected=null; render(); break;
await req('DELETE', `/api/books/${d.id}`);
removeNode('book', d.id);
S.selected = null;
render();
break;
}
case 'save-book': {
const data = {
title: document.getElementById('d-title')?.value || '',
author: document.getElementById('d-author')?.value || '',
year: document.getElementById('d-year')?.value || '',
isbn: document.getElementById('d-isbn')?.value || '',
publisher: document.getElementById('d-pub')?.value || '',
notes: document.getElementById('d-notes')?.value || '',
title: document.getElementById('d-title')?.value || '',
author: document.getElementById('d-author')?.value || '',
year: document.getElementById('d-year')?.value || '',
isbn: document.getElementById('d-isbn')?.value || '',
publisher: document.getElementById('d-pub')?.value || '',
notes: document.getElementById('d-notes')?.value || '',
};
const res = await req('PUT',`/api/books/${d.id}`,data);
walkTree(n => {
const res = await req('PUT', `/api/books/${d.id}`, data);
walkTree((n) => {
if (n.id === d.id) {
Object.assign(n, data);
n.ai_title = data.title; n.ai_author = data.author; n.ai_year = data.year;
n.ai_isbn = data.isbn; n.ai_publisher = data.publisher;
n.ai_title = data.title;
n.ai_author = data.author;
n.ai_year = data.year;
n.ai_isbn = data.isbn;
n.ai_publisher = data.publisher;
n.identification_status = res.identification_status ?? n.identification_status;
}
});
toast('Saved'); render(); break;
toast('Saved');
render();
break;
}
case 'run-plugin': {
const key = `${d.plugin}:${d.id}`;
S._loading[key] = true; renderDetail();
// Capture any unsaved field edits before the first renderDetail() overwrites them.
if (d.etype === 'books') {
walkTree((n) => {
if (n.id === d.id) {
n.title = document.getElementById('d-title')?.value ?? n.title;
n.author = document.getElementById('d-author')?.value ?? n.author;
n.year = document.getElementById('d-year')?.value ?? n.year;
n.isbn = document.getElementById('d-isbn')?.value ?? n.isbn;
n.publisher = document.getElementById('d-pub')?.value ?? n.publisher;
n.notes = document.getElementById('d-notes')?.value ?? n.notes;
}
});
}
S._loading[key] = true;
renderDetail();
try {
const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`);
walkTree(n => { if (n.id === d.id) Object.assign(n, res); });
} catch(err) { toast(`${d.plugin} failed: ${err.message}`); }
delete S._loading[key]; renderDetail();
walkTree((n) => {
if (n.id !== d.id) return;
if (d.etype === 'books') {
// Server response must not overwrite user edits captured above.
const saved = {
title: n.title,
author: n.author,
year: n.year,
isbn: n.isbn,
publisher: n.publisher,
notes: n.notes,
};
Object.assign(n, res);
Object.assign(n, saved);
} else {
Object.assign(n, res);
}
});
} catch (err) {
toast(`${d.plugin} failed: ${err.message}`);
}
delete S._loading[key];
renderDetail();
break;
}
case 'select-bnd-plugin': {
if (_bnd) { _bnd.selectedPlugin = e.target.value || null; drawBnd(); }
if (_bnd) {
_bnd.selectedPlugin = e.target.value || null;
drawBnd();
}
break;
}
case 'accept-field': {
const inp = document.getElementById(d.input);
if (inp) inp.value = d.value;
walkTree(n => { if (n.id === d.id) n[d.field] = d.value; });
renderDetail(); break;
walkTree((n) => {
if (n.id === d.id) n[d.field] = d.value;
});
renderDetail();
break;
}
case 'dismiss-field': {
const res = await req('POST', `/api/books/${d.id}/dismiss-field`, {field: d.field, value: d.value || ''});
walkTree(n => {
const res = await req('POST', `/api/books/${d.id}/dismiss-field`, { field: d.field, value: d.value || '' });
walkTree((n) => {
if (n.id === d.id) {
n.candidates = JSON.stringify(res.candidates || []);
if (!d.value) n[`ai_${d.field}`] = n[d.field] || '';
n.identification_status = res.identification_status ?? n.identification_status;
}
});
renderDetail(); break;
renderDetail();
break;
}
case 'identify-book': {
const key = `identify:${d.id}`;
S._loading[key] = true;
renderDetail();
try {
const res = await req('POST', `/api/books/${d.id}/identify`);
walkTree((n) => {
if (n.id !== d.id) return;
const saved = {
title: n.title,
author: n.author,
year: n.year,
isbn: n.isbn,
publisher: n.publisher,
notes: n.notes,
};
Object.assign(n, res);
Object.assign(n, saved);
});
} catch (err) {
toast(`Identify failed: ${err.message}`);
}
delete S._loading[key];
renderDetail();
break;
}
case 'toggle-ai-blocks': {
walkTree((n) => {
if (n.id === d.id) _aiBlocksVisible[d.id] = !aiBlocksShown(n);
});
renderDetail();
break;
}
case 'apply-ai-block': {
let block;
try {
block = JSON.parse(d.block);
} catch {
break;
}
const fieldMap = { title: 'd-title', author: 'd-author', year: 'd-year', isbn: 'd-isbn', publisher: 'd-pub' };
for (const [field, inputId] of Object.entries(fieldMap)) {
const v = (block[field] || '').trim();
if (!v) continue;
const inp = document.getElementById(inputId);
if (inp) inp.value = v;
walkTree((n) => {
if (n.id === d.id) n[field] = v;
});
}
renderDetail();
break;
}
case 'batch-start': {
const res = await req('POST', '/api/batch');
if (res.already_running) { toast('Batch already running'); break; }
if (!res.started) { toast('No unidentified books'); break; }
_batchState = {running: true, total: res.total, done: 0, errors: 0, current: ''};
startBatchPolling(); renderDetail(); break;
if (res.already_running) {
toast(res.added > 0 ? `Added ${res.added} book(s) to batch` : 'Batch already running');
if (!_batchWs) connectBatchWs();
break;
}
if (!res.started) {
toast('No unidentified books');
break;
}
connectBatchWs();
renderDetail();
break;
}
case 'open-img-popup': {
const popup = document.getElementById('img-popup');
if (!popup) break;
document.getElementById('img-popup-img').src = d.src;
popup.classList.add('open');
break;
}
// Photo
case 'photo': triggerPhoto(d.type, d.id); break;
case 'photo':
triggerPhoto(d.type, d.id);
break;
// Crop
case 'crop-start': startCropMode(d.type, d.id); break;
case 'crop-start':
startCropMode(d.type, d.id);
break;
// Photo queue
case 'photo-queue-start': {
const node = findNode(d.id);
if (!node) break;
const books = collectQueueBooks(node, d.type);
if (!books.length) { toast('No unidentified books'); break; }
_photoQueue = {books, index: 0, processing: false};
if (!books.length) {
toast('No unidentified books');
break;
}
_photoQueue = { books, index: 0, processing: false };
renderPhotoQueue();
break;
}
@@ -278,6 +449,5 @@ async function handle(action, d, e) {
renderPhotoQueue();
break;
}
}
}

View File

@@ -6,16 +6,25 @@
* Provides: esc(), toast(), isDesktop()
*/
/* exported esc, toast, isDesktop */
// ── Helpers ─────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function toast(msg, dur = 2800) {
const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('on');
el.textContent = msg;
el.classList.add('on');
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('on'), dur);
}
function isDesktop() { return window.innerWidth >= 768; }
function isDesktop() {
return window.innerWidth >= 768;
}

View File

@@ -10,16 +10,18 @@
* renderDetail() does a cheaper in-place update of the right panel only,
* used during plugin runs and field edits to avoid re-rendering the sidebar.
*
* Depends on: S, _plugins, _batchState, _batchPollTimer (state.js);
* Depends on: S, _plugins, _batchState, _batchWs (state.js);
* req, toast (api.js / helpers.js); isDesktop (helpers.js);
* vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn
* (tree-render.js / detail-render.js);
* attachEditables, initSortables (editing.js);
* setupDetailCanvas (canvas-boundary.js)
* Provides: render(), renderDetail(), loadConfig(), startBatchPolling(),
* Provides: render(), renderDetail(), loadConfig(), connectBatchWs(),
* loadTree()
*/
/* exported render, renderDetail, connectBatchWs, connectAiLogWs, loadTree */
// ── Full re-render ────────────────────────────────────────────────────────────
function render() {
if (document.activeElement?.contentEditable === 'true') return;
@@ -37,46 +39,121 @@ function renderDetail() {
const body = document.getElementById('main-body');
if (body) body.innerHTML = vDetailBody();
const t = document.getElementById('main-title');
if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML span
if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML string
const hb = document.getElementById('main-hdr-btns');
if (hb) hb.innerHTML = mainHeaderBtns();
const bb = document.getElementById('main-hdr-batch');
if (bb) bb.innerHTML = vBatchBtn();
attachEditables(); // pick up the new editable span in the header
attachEditables(); // pick up the new editable span in the header
requestAnimationFrame(setupDetailCanvas);
}
// ── Data loading ──────────────────────────────────────────────────────────────
async function loadConfig() {
try {
const cfg = await req('GET','/api/config');
const cfg = await req('GET', '/api/config');
window._grabPx = cfg.boundary_grab_px ?? 14;
window._confidenceThreshold = cfg.confidence_threshold ?? 0.8;
window._aiLogMax = cfg.ai_log_max_entries ?? 100;
_plugins = cfg.plugins || [];
} catch { window._grabPx = 14; window._confidenceThreshold = 0.8; }
} catch {
window._grabPx = 14;
window._confidenceThreshold = 0.8;
window._aiLogMax = 100;
}
}
function startBatchPolling() {
if (_batchPollTimer) clearInterval(_batchPollTimer);
_batchPollTimer = setInterval(async () => {
try {
const st = await req('GET', '/api/batch/status');
_batchState = st;
const bb = document.getElementById('main-hdr-batch');
if (bb) bb.innerHTML = vBatchBtn();
if (!st.running) {
clearInterval(_batchPollTimer); _batchPollTimer = null;
toast(`Batch: ${st.done} done, ${st.errors} errors`);
await loadTree();
function connectBatchWs() {
if (_batchWs) {
_batchWs.close();
_batchWs = null;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/batch`);
_batchWs = ws;
ws.onmessage = async (ev) => {
const st = JSON.parse(ev.data);
_batchState = st;
const bb = document.getElementById('main-hdr-batch');
if (bb) bb.innerHTML = vBatchBtn();
if (!st.running) {
ws.close();
_batchWs = null;
toast(`Batch: ${st.done} done, ${st.errors} errors`);
await loadTree();
}
};
ws.onerror = () => {
_batchWs = null;
};
ws.onclose = () => {
_batchWs = null;
};
}
function connectAiLogWs() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/ai-log`);
_aiLogWs = ws;
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') {
_aiLog = msg.entries || [];
} else if (msg.type === 'update') {
const entry = msg.entry;
const idx = _aiLog.findIndex((e) => e.id === entry.id);
if (idx >= 0) {
_aiLog[idx] = entry;
} else {
_aiLog.push(entry);
const max = window._aiLogMax ?? 100;
if (_aiLog.length > max) _aiLog.splice(0, _aiLog.length - max);
}
} catch { /* ignore poll errors */ }
}, 2000);
} else if (msg.type === 'entity_update') {
const etype = msg.entity_type.slice(0, -1); // "books" → "book"
walkTree((n) => {
if (n.id === msg.entity_id) Object.assign(n, msg.data);
});
if (S.selected && S.selected.type === etype && S.selected.id === msg.entity_id) {
renderDetail();
} else {
render(); // update sidebar badges
}
return; // skip AI indicator update — not a log entry
}
// Update header AI indicator
const hdr = document.getElementById('hdr-ai-indicator');
if (hdr) {
const running = _aiLog.filter((e) => e.status === 'running').length;
hdr.innerHTML = running > 0 ? vAiIndicator(running) : '';
}
// Update root detail panel if shown
if (!S.selected) renderDetail();
};
ws.onerror = () => {};
ws.onclose = () => {
// Reconnect after a short delay
setTimeout(connectAiLogWs, 3000);
};
}
async function loadTree() {
S.tree = await req('GET','/api/tree');
S.tree = await req('GET', '/api/tree');
render();
}
// ── Init ──────────────────────────────────────────────────────────────────────
Promise.all([loadConfig(), loadTree()]);
// Image popup: close when clicking the overlay background or the × button.
(function () {
const popup = document.getElementById('img-popup');
const closeBtn = document.getElementById('img-popup-close');
if (popup) {
popup.addEventListener('click', (e) => {
if (e.target === popup) popup.classList.remove('open');
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => popup && popup.classList.remove('open'));
}
})();
Promise.all([loadConfig(), loadTree()]).then(() => connectAiLogWs());

View File

@@ -21,6 +21,8 @@
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
*/
/* exported collectQueueBooks, renderPhotoQueue, triggerPhoto */
// ── Photo Queue ──────────────────────────────────────────────────────────────
function collectQueueBooks(node, type) {
const books = [];
@@ -29,9 +31,9 @@ function collectQueueBooks(node, type) {
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'));
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;
@@ -40,8 +42,12 @@ function collectQueueBooks(node, type) {
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;
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">
@@ -79,8 +85,8 @@ function renderPhotoQueue() {
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');
S._photoTarget = { type, id };
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture', 'environment');
else gphoto.removeAttribute('capture');
gphoto.value = '';
gphoto.click();
@@ -89,20 +95,22 @@ function triggerPhoto(type, id) {
gphoto.addEventListener('change', async () => {
const file = gphoto.files[0];
if (!file || !S._photoTarget) return;
const {type, id} = S._photoTarget;
const { type, id } = S._photoTarget;
S._photoTarget = null;
const fd = new FormData();
fd.append('image', file, file.name); // HD — no client-side compression
fd.append('image', file, file.name); // HD — no client-side compression
const urls = {
room: `/api/rooms/${id}/photo`,
room: `/api/rooms/${id}/photo`,
cabinet: `/api/cabinets/${id}/photo`,
shelf: `/api/shelves/${id}/photo`,
book: `/api/books/${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]; });
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;
@@ -111,8 +119,12 @@ gphoto.addEventListener('change', async () => {
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 */ }
walkTree((n) => {
if (n.id === id) Object.assign(n, br);
});
} catch {
/* continue queue on process error */
}
}
_photoQueue.processing = false;
_photoQueue.index++;
@@ -127,12 +139,24 @@ gphoto.addEventListener('change', async () => {
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); });
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); }
} catch {
toast('Photo saved');
}
} else {
toast('Photo saved');
}
} else {
toast('Photo saved');
}
} else {
toast('Photo saved');
}
} catch (err) {
toast('Upload failed: ' + err.message);
}
});

View File

@@ -7,35 +7,53 @@
* S — main UI state (tree data, selection, loading flags)
* _plugins — plugin manifest populated from GET /api/config
* _batchState — current batch-processing progress
* _batchPollTimer — setInterval handle for batch polling
* _batchWs — active WebSocket for batch push notifications (null when idle)
* _bnd — live boundary-canvas state (written by canvas-boundary.js,
* read by detail-render.js)
* _photoQueue — photo queue session state (written by photo.js,
* read by events.js)
*/
/* exported S */
// ── Main UI state ───────────────────────────────────────────────────────────
let S = {
const S = {
tree: null,
expanded: new Set(),
selected: null, // {type:'cabinet'|'shelf'|'book', id}
_photoTarget: null, // {type, id}
_loading: {}, // {`${pluginId}:${entityId}`: true}
_cropMode: null, // {type, id} while crop UI is active
selected: null, // {type:'cabinet'|'shelf'|'book', id}
_photoTarget: null, // {type, id}
_loading: {}, // {`${pluginId}:${entityId}`: true}
_cropMode: null, // {type, id} while crop UI is active
};
// ── Plugin registry ─────────────────────────────────────────────────────────
let _plugins = []; // populated from GET /api/config
// eslint-disable-next-line prefer-const
let _plugins = []; // populated from GET /api/config
// ── Batch processing state ──────────────────────────────────────────────────
let _batchState = {running: false, total: 0, done: 0, errors: 0, current: ''};
let _batchPollTimer = null;
const _batchState = { running: false, total: 0, done: 0, errors: 0, current: '' };
// eslint-disable-next-line prefer-const
let _batchWs = null;
// ── Boundary canvas live state ───────────────────────────────────────────────
// Owned by canvas-boundary.js; declared here so detail-render.js can read it
// without a circular load dependency.
// eslint-disable-next-line prefer-const
let _bnd = null; // {wrap,img,canvas,axis,boundaries[],pluginResults{},selectedPlugin,segments[],nodeId,nodeType}
// ── Photo queue session state ────────────────────────────────────────────────
// Owned by photo.js; declared here so events.js can read/write it.
// eslint-disable-next-line prefer-const
let _photoQueue = null; // {books:[...], index:0, processing:false}
// ── AI blocks visibility ─────────────────────────────────────────────────────
// Per-book override map. If bookId is absent the default rule applies:
// show when not user_approved, hide when user_approved.
const _aiBlocksVisible = {}; // {bookId: true|false}
// ── AI request log ───────────────────────────────────────────────────────────
// Populated from /ws/ai-log on page load.
// eslint-disable-next-line prefer-const
let _aiLog = []; // AiLogEntry[] — ring buffer, oldest first
// eslint-disable-next-line prefer-const
let _aiLogWs = null; // active WebSocket for AI log push (never closed)

View File

@@ -13,17 +13,27 @@
* vBook(), getBookStats(), vAiProgressBar()
*/
/* exported pluginsByCategory, pluginsByTarget, isLoading, vPluginBtn, vBatchBtn, vAiIndicator,
candidateSugRows, vApp, mainTitle, mainHeaderBtns, _STATUS_BADGE,
getBookStats, vAiProgressBar, walkTree, removeNode, findNode */
// ── Plugin helpers ───────────────────────────────────────────────────────────
function pluginsByCategory(cat) { return _plugins.filter(p => p.category === cat); }
function pluginsByTarget(cat, target) { return _plugins.filter(p => p.category === cat && p.target === target); }
function isLoading(pluginId, entityId) { return !!S._loading[`${pluginId}:${entityId}`]; }
function pluginsByCategory(cat) {
return _plugins.filter((p) => p.category === cat);
}
function pluginsByTarget(cat, target) {
return _plugins.filter((p) => p.category === cat && p.target === target);
}
function isLoading(pluginId, entityId) {
return !!S._loading[`${pluginId}:${entityId}`];
}
function vPluginBtn(plugin, entityId, entityType, extraDisabled = false) {
const loading = isLoading(plugin.id, entityId);
const label = loading ? '⏳' : esc(plugin.name);
return `<button class="btn btn-s" style="padding:2px 7px;font-size:.78rem;min-height:0"
data-a="run-plugin" data-plugin="${plugin.id}" data-id="${entityId}"
data-etype="${entityType}"${(loading||extraDisabled)?' disabled':''}
data-etype="${entityType}"${loading || extraDisabled ? ' disabled' : ''}
title="${esc(plugin.name)}">${label}</button>`;
}
@@ -34,21 +44,36 @@ function vBatchBtn() {
return `<button class="hbtn" data-a="batch-start" title="Analyze all unidentified books">🔄</button>`;
}
// ── AI active indicator ───────────────────────────────────────────────────────
function vAiIndicator(count) {
return `<span class="ai-indicator" title="${count} AI request${count === 1 ? '' : 's'} running"><span class="ai-dot"></span>${count}</span>`;
}
// ── Candidate suggestion rows ────────────────────────────────────────────────
const SOURCE_LABELS = {
vlm: 'VLM', ai: 'AI', openlibrary: 'OpenLib',
rsl: 'РГБ', rusneb: 'НЭБ', alib: 'Alib', nlr: 'НЛР', shpl: 'ШПИЛ',
vlm: 'VLM',
ai: 'AI',
openlibrary: 'OpenLib',
rsl: 'РГБ',
rusneb: 'НЭБ',
alib: 'Alib',
nlr: 'НЛР',
shpl: 'ШПИЛ',
};
function getSourceLabel(source) {
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source];
const p = _plugins.find(pl => pl.id === source);
const p = _plugins.find((pl) => pl.id === source);
return p ? p.name : source;
}
function parseCandidates(json) {
if (!json) return [];
try { return JSON.parse(json) || []; } catch { return []; }
try {
return JSON.parse(json) || [];
} catch {
return [];
}
}
function candidateSugRows(b, field, inputId) {
@@ -61,7 +86,7 @@ function candidateSugRows(b, field, inputId) {
const v = (c[field] || '').trim();
if (!v) continue;
const key = v.toLowerCase();
if (!byVal.has(key)) byVal.set(key, {display: v, sources: []});
if (!byVal.has(key)) byVal.set(key, { display: v, sources: [] });
const entry = byVal.get(key);
if (!entry.sources.includes(c.source)) entry.sources.push(c.source);
}
@@ -69,17 +94,17 @@ function candidateSugRows(b, field, inputId) {
const aiVal = (b[`ai_${field}`] || '').trim();
if (aiVal) {
const key = aiVal.toLowerCase();
if (!byVal.has(key)) byVal.set(key, {display: aiVal, sources: []});
if (!byVal.has(key)) byVal.set(key, { display: aiVal, sources: [] });
const entry = byVal.get(key);
if (!entry.sources.includes('ai')) entry.sources.unshift('ai');
}
return [...byVal.entries()]
.filter(([k]) => k !== userVal.toLowerCase())
.map(([, {display, sources}]) => {
const badges = sources.map(s =>
`<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>`
).join(' ');
.map(([, { display, sources }]) => {
const badges = sources
.map((s) => `<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>`)
.join(' ');
const val = esc(display);
return `<div class="ai-sug">
${badges} <em>${val}</em>
@@ -90,34 +115,41 @@ function candidateSugRows(b, field, inputId) {
data-a="dismiss-field" data-id="${b.id}" data-field="${field}"
data-value="${val}" title="Dismiss">✗</button>
</div>`;
}).join('');
})
.join('');
}
// ── App shell ────────────────────────────────────────────────────────────────
function vApp() {
return `<div class="layout">
<div class="sidebar">
<div class="hdr"><h1 data-a="deselect" style="cursor:pointer" title="Back to overview">📚 Bookshelf</h1></div>
<div class="sidebar-body">
${vTreeBody()}
<button class="add-root" data-a="add-room">+ Add Room</button>
</div>
const running = (_aiLog || []).filter((e) => e.status === 'running').length;
return `<div class="page-wrap">
<div class="hdr">
<h1 data-a="deselect" style="cursor:pointer;flex:1" title="Back to overview">📚 Bookshelf</h1>
<div id="hdr-ai-indicator">${running > 0 ? vAiIndicator(running) : ''}</div>
<div id="main-hdr-batch">${vBatchBtn()}</div>
</div>
<div class="main-panel">
<div class="main-hdr" id="main-hdr">
<h2 id="main-title">${mainTitle()}</h2>
<div id="main-hdr-batch">${vBatchBtn()}</div>
<div id="main-hdr-btns">${mainHeaderBtns()}</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar-body">
${vTreeBody()}
<button class="add-root" data-a="add-room">+ Add Room</button>
</div>
</div>
<div class="main-panel">
<div class="main-hdr" id="main-hdr">
<h2 id="main-title">${mainTitle()}</h2>
<div id="main-hdr-btns">${mainHeaderBtns()}</div>
</div>
<div class="main-body" id="main-body">${vDetailBody()}</div>
</div>
<div class="main-body" id="main-body">${vDetailBody()}</div>
</div>
</div>`;
}
function mainTitle() {
if (!S.selected) return '<span style="opacity:.7">Select a room, cabinet or shelf</span>';
if (!S.selected) return '📚 Bookshelf';
const n = findNode(S.selected.id);
const {type, id} = S.selected;
const { type, id } = S.selected;
if (type === 'book') {
return `<span>${esc(n?.title || 'Untitled book')}</span>`;
}
@@ -127,7 +159,7 @@ function mainTitle() {
function mainHeaderBtns() {
if (!S.selected) return '';
const {type, id} = S.selected;
const { type, id } = S.selected;
if (type === 'room') {
return `<div style="display:flex;gap:2px">
<button class="hbtn" data-a="add-cabinet" data-id="${id}" title="Add cabinet"></button>
@@ -171,18 +203,22 @@ function vRoom(r) {
const exp = S.expanded.has(r.id);
const sel = S.selected?.id === r.id;
return `<div class="node" data-id="${r.id}" data-type="room">
<div class="nrow nrow-room${sel?' sel':''}" data-a="select" data-type="room" data-id="${r.id}">
<div class="nrow nrow-room${sel ? ' sel' : ''}" data-a="select" data-type="room" data-id="${r.id}">
<span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button>
<button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button>
<span class="nname" data-type="room" data-id="${r.id}">🏠 ${esc(r.name)}</span>
<div class="nacts">
<button class="ibtn" data-a="add-cabinet" data-id="${r.id}" title="Add cabinet"></button>
<button class="ibtn" data-a="del-room" data-id="${r.id}" title="Delete">🗑</button>
</div>
</div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}">
${
exp
? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}">
${r.cabinets.map(vCabinet).join('')}
</div></div>` : ''}
</div></div>`
: ''
}
</div>`;
}
@@ -190,9 +226,9 @@ function vCabinet(c) {
const exp = S.expanded.has(c.id);
const sel = S.selected?.id === c.id;
return `<div class="node" data-id="${c.id}" data-type="cabinet">
<div class="nrow nrow-cabinet${sel?' sel':''}" data-a="select" data-type="cabinet" data-id="${c.id}">
<div class="nrow nrow-cabinet${sel ? ' sel' : ''}" data-a="select" data-type="cabinet" data-id="${c.id}">
<span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button>
<button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button>
${c.photo_filename ? `<img src="/images/${c.photo_filename}" style="width:26px;height:32px;object-fit:cover;border-radius:2px;flex-shrink:0" alt="">` : ''}
<span class="nname" data-type="cabinet" data-id="${c.id}">📚 ${esc(c.name)}</span>
<div class="nacts">
@@ -202,9 +238,13 @@ function vCabinet(c) {
${!isDesktop() ? `<button class="ibtn" data-a="del-cabinet" data-id="${c.id}" title="Delete">🗑</button>` : ''}
</div>
</div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}">
${
exp
? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}">
${c.shelves.map(vShelf).join('')}
</div></div>` : ''}
</div></div>`
: ''
}
</div>`;
}
@@ -212,9 +252,9 @@ function vShelf(s) {
const exp = S.expanded.has(s.id);
const sel = S.selected?.id === s.id;
return `<div class="node" data-id="${s.id}" data-type="shelf">
<div class="nrow nrow-shelf${sel?' sel':''}" data-a="select" data-type="shelf" data-id="${s.id}">
<div class="nrow nrow-shelf${sel ? ' sel' : ''}" data-a="select" data-type="shelf" data-id="${s.id}">
<span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button>
<button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button>
<span class="nname" data-type="shelf" data-id="${s.id}">${esc(s.name)}</span>
<div class="nacts">
${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="shelf" data-id="${s.id}" title="Photo">📷</button>` : ''}
@@ -223,14 +263,18 @@ function vShelf(s) {
${!isDesktop() ? `<button class="ibtn" data-a="del-shelf" data-id="${s.id}" title="Delete">🗑</button>` : ''}
</div>
</div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}">
${
exp
? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}">
${s.books.map(vBook).join('')}
</div></div>` : ''}
</div></div>`
: ''
}
</div>`;
}
const _STATUS_BADGE = {
unidentified: ['s-unid', '?'],
unidentified: ['s-unid', '?'],
ai_identified: ['s-aiid', 'AI'],
user_approved: ['s-appr', '✓'],
};
@@ -240,7 +284,7 @@ function vBook(b) {
const sub = [b.author, b.year].filter(Boolean).join(' · ');
const sel = S.selected?.id === b.id;
return `<div class="node" data-id="${b.id}" data-type="book">
<div class="nrow nrow-book${sel?' sel':''}" data-a="select" data-type="book" data-id="${b.id}">
<div class="nrow nrow-book${sel ? ' sel' : ''}" data-a="select" data-type="book" data-id="${b.id}">
<span class="drag-h">⠿</span>
<span class="sbadge ${sc}" title="${b.identification_status ?? 'unidentified'}">${sl}</span>
${b.image_filename ? `<img src="/images/${b.image_filename}" class="bthumb" alt="">` : `<div class="bthumb-ph">📖</div>`}
@@ -248,10 +292,14 @@ function vBook(b) {
<div class="bttl">${esc(b.title || '—')}</div>
${sub ? `<div class="bsub">${esc(sub)}</div>` : ''}
</div>
${!isDesktop() ? `<div class="nacts">
${
!isDesktop()
? `<div class="nacts">
<button class="ibtn" data-a="photo" data-type="book" data-id="${b.id}" title="Upload photo">📷</button>
<button class="ibtn" data-a="del-book" data-id="${b.id}" title="Delete">🗑</button>
</div>` : ''}
</div>`
: ''
}
</div>
</div>`;
}
@@ -260,26 +308,29 @@ function vBook(b) {
function getBookStats(node, type) {
const books = [];
function collect(n, t) {
if (t==='book') { 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'));
if (t === 'book') {
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 {
total: books.length,
approved: books.filter(b=>b.identification_status==='user_approved').length,
ai: books.filter(b=>b.identification_status==='ai_identified').length,
unidentified: books.filter(b=>b.identification_status==='unidentified').length,
approved: books.filter((b) => b.identification_status === 'user_approved').length,
ai: books.filter((b) => b.identification_status === 'ai_identified').length,
unidentified: books.filter((b) => b.identification_status === 'unidentified').length,
};
}
function vAiProgressBar(stats) {
const {total, approved, ai, unidentified} = stats;
const { total, approved, ai, unidentified } = stats;
if (!total || approved === total) return '';
const pA = (approved/total*100).toFixed(1);
const pI = (ai/total*100).toFixed(1);
const pU = (unidentified/total*100).toFixed(1);
const pA = ((approved / total) * 100).toFixed(1);
const pI = ((ai / total) * 100).toFixed(1);
const pU = ((unidentified / total) * 100).toFixed(1);
return `<div style="margin-bottom:10px;background:white;border-radius:8px;padding:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)">
<div style="display:flex;gap:8px;font-size:.7rem;margin-bottom:5px">
<span style="color:#15803d">✓ ${approved} approved</span><span style="color:#94a3b8">·</span>
@@ -297,10 +348,13 @@ function vAiProgressBar(stats) {
// ── Tree helpers ─────────────────────────────────────────────────────────────
function walkTree(fn) {
if (!S.tree) return;
for (const r of S.tree) { fn(r,'room');
for (const c of r.cabinets) { fn(c,'cabinet');
for (const s of c.shelves) { fn(s,'shelf');
for (const b of s.books) fn(b,'book');
for (const r of S.tree) {
fn(r, 'room');
for (const c of r.cabinets) {
fn(c, 'cabinet');
for (const s of c.shelves) {
fn(s, 'shelf');
for (const b of s.books) fn(b, 'book');
}
}
}
@@ -308,14 +362,20 @@ function walkTree(fn) {
function removeNode(type, id) {
if (!S.tree) return;
if (type==='room') S.tree = S.tree.filter(r=>r.id!==id);
if (type==='cabinet') S.tree.forEach(r=>r.cabinets=r.cabinets.filter(c=>c.id!==id));
if (type==='shelf') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves=c.shelves.filter(s=>s.id!==id)));
if (type==='book') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>s.books=s.books.filter(b=>b.id!==id))));
if (type === 'room') S.tree = S.tree.filter((r) => r.id !== id);
if (type === 'cabinet') S.tree.forEach((r) => (r.cabinets = r.cabinets.filter((c) => c.id !== id)));
if (type === 'shelf')
S.tree.forEach((r) => r.cabinets.forEach((c) => (c.shelves = c.shelves.filter((s) => s.id !== id))));
if (type === 'book')
S.tree.forEach((r) =>
r.cabinets.forEach((c) => c.shelves.forEach((s) => (s.books = s.books.filter((b) => b.id !== id)))),
);
}
function findNode(id) {
let found = null;
walkTree(n => { if (n.id===id) found=n; });
walkTree((n) => {
if (n.id === id) found = n;
});
return found;
}