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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user