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:
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,25 @@
|
||||
* Provides: esc(), toast(), isDesktop()
|
||||
*/
|
||||
|
||||
/* exported esc, toast, isDesktop */
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user