- Extract 8 common route patterns into factory functions in routes/shared.py (favourite, upload, replace cover, save defaults, clone, save JSON, get missing, clear covers) — removes ~1,100 lines across 9 route files - Extract generic _sync_category() in sync.py — 7 sync functions become one-liner wrappers, removing ~350 lines - Extract shared detail page JS into static/js/detail-common.js — all 9 detail templates now call initDetailPage() with minimal config - Extract layout inline JS into static/js/layout-utils.js (~185 lines) - Extract library toolbar JS into static/js/library-toolbar.js - Fix finalize missing-image bug: raise RuntimeError instead of logging warning so job is marked failed - Fix missing scheduler default in _default_checkpoint_data() - Fix N+1 query in Character.get_available_outfits() with batch IN query - Convert all print() to logger across services and routes - Add missing tags display to styles, scenes, detailers, checkpoints detail - Update delete buttons to use trash.png icon with solid red background - Update CLAUDE.md to reflect new architecture Net reduction: ~1,600 lines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
205 lines
8.1 KiB
JavaScript
205 lines
8.1 KiB
JavaScript
/**
|
|
* Shared utility functions used across the app layout.
|
|
* Previously defined inline in layout.html.
|
|
*/
|
|
|
|
/**
|
|
* Confirm and execute a resource delete (soft or hard).
|
|
* Called from the delete confirmation modal.
|
|
*/
|
|
async function confirmResourceDelete(mode) {
|
|
const resourceDeleteModal = bootstrap.Modal.getInstance(
|
|
document.getElementById('resourceDeleteModal'));
|
|
resourceDeleteModal.hide();
|
|
try {
|
|
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({mode}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'ok') {
|
|
const card = document.getElementById(`card-${_rdmSlug}`);
|
|
if (card) card.remove();
|
|
} else {
|
|
alert('Delete failed: ' + (data.error || 'unknown error'));
|
|
}
|
|
} catch (e) {
|
|
alert('Delete failed: ' + e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Regenerate tags for a single resource via the LLM queue.
|
|
* Called from detail page tag regeneration buttons.
|
|
*/
|
|
function regenerateTags(category, slug) {
|
|
const btn = document.getElementById('regenerate-tags-btn');
|
|
if (!btn) return;
|
|
const origText = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating\u2026';
|
|
fetch(`/api/${category}/${slug}/regenerate_tags`, {
|
|
method: 'POST',
|
|
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
|
})
|
|
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
|
|
.then(({ok, data}) => {
|
|
if (ok && data.success) {
|
|
location.reload();
|
|
} else {
|
|
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
|
|
btn.disabled = false;
|
|
btn.innerHTML = origText;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
alert('Regeneration failed: ' + err);
|
|
btn.disabled = false;
|
|
btn.innerHTML = origText;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize the JSON editor modal for a resource detail page.
|
|
* Provides simple form view and raw JSON (advanced) editing.
|
|
*/
|
|
function initJsonEditor(saveUrl) {
|
|
const jsonModal = document.getElementById('jsonEditorModal');
|
|
if (!jsonModal) return;
|
|
const textarea = document.getElementById('json-editor-textarea');
|
|
const errBox = document.getElementById('json-editor-error');
|
|
const simplePanel = document.getElementById('json-simple-panel');
|
|
const advancedPanel = document.getElementById('json-advanced-panel');
|
|
const simpleTab = document.getElementById('json-simple-tab');
|
|
const advancedTab = document.getElementById('json-advanced-tab');
|
|
let activeTab = 'simple';
|
|
|
|
function buildSimpleForm(data) {
|
|
simplePanel.innerHTML = '';
|
|
for (const [key, value] of Object.entries(data)) {
|
|
const row = document.createElement('div');
|
|
row.className = 'row mb-2 align-items-start';
|
|
|
|
const labelCol = document.createElement('div');
|
|
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
|
|
labelCol.textContent = key.replace(/_/g, ' ');
|
|
|
|
const inputCol = document.createElement('div');
|
|
inputCol.className = 'col-sm-9';
|
|
|
|
let el;
|
|
if (typeof value === 'boolean') {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'form-check mt-2';
|
|
el = document.createElement('input');
|
|
el.type = 'checkbox';
|
|
el.className = 'form-check-input';
|
|
el.checked = value;
|
|
el.dataset.dtype = 'boolean';
|
|
wrap.appendChild(el);
|
|
inputCol.appendChild(wrap);
|
|
} else if (typeof value === 'number') {
|
|
el = document.createElement('input');
|
|
el.type = 'number';
|
|
el.step = 'any';
|
|
el.className = 'form-control form-control-sm';
|
|
el.value = value;
|
|
el.dataset.dtype = 'number';
|
|
inputCol.appendChild(el);
|
|
} else if (typeof value === 'string') {
|
|
if (value.length > 80) {
|
|
el = document.createElement('textarea');
|
|
el.className = 'form-control form-control-sm';
|
|
el.rows = 3;
|
|
} else {
|
|
el = document.createElement('input');
|
|
el.type = 'text';
|
|
el.className = 'form-control form-control-sm';
|
|
}
|
|
el.value = value;
|
|
el.dataset.dtype = 'string';
|
|
inputCol.appendChild(el);
|
|
} else {
|
|
el = document.createElement('textarea');
|
|
el.className = 'form-control form-control-sm font-monospace';
|
|
const lines = JSON.stringify(value, null, 2).split('\n');
|
|
el.rows = Math.min(10, lines.length + 1);
|
|
el.value = JSON.stringify(value, null, 2);
|
|
el.dataset.dtype = 'json';
|
|
inputCol.appendChild(el);
|
|
}
|
|
el.dataset.key = key;
|
|
row.appendChild(labelCol);
|
|
row.appendChild(inputCol);
|
|
simplePanel.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function readSimpleForm() {
|
|
const result = {};
|
|
simplePanel.querySelectorAll('[data-key]').forEach(el => {
|
|
const key = el.dataset.key;
|
|
const dtype = el.dataset.dtype;
|
|
if (dtype === 'boolean') result[key] = el.checked;
|
|
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
|
|
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
|
|
else result[key] = el.value;
|
|
});
|
|
return result;
|
|
}
|
|
|
|
simpleTab.addEventListener('click', () => {
|
|
errBox.classList.add('d-none');
|
|
let data;
|
|
try { data = JSON.parse(textarea.value); }
|
|
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON \u2014 ' + e.message; errBox.classList.remove('d-none'); return; }
|
|
buildSimpleForm(data);
|
|
simplePanel.classList.remove('d-none');
|
|
advancedPanel.classList.add('d-none');
|
|
simpleTab.classList.add('active');
|
|
advancedTab.classList.remove('active');
|
|
activeTab = 'simple';
|
|
});
|
|
|
|
advancedTab.addEventListener('click', () => {
|
|
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
|
|
advancedPanel.classList.remove('d-none');
|
|
simplePanel.classList.add('d-none');
|
|
advancedTab.classList.add('active');
|
|
simpleTab.classList.remove('active');
|
|
activeTab = 'advanced';
|
|
});
|
|
|
|
jsonModal.addEventListener('show.bs.modal', () => {
|
|
const raw = document.getElementById('json-raw-data').textContent;
|
|
let data;
|
|
try { data = JSON.parse(raw); } catch { data = {}; }
|
|
buildSimpleForm(data);
|
|
textarea.value = JSON.stringify(data, null, 2);
|
|
simplePanel.classList.remove('d-none');
|
|
advancedPanel.classList.add('d-none');
|
|
simpleTab.classList.add('active');
|
|
advancedTab.classList.remove('active');
|
|
activeTab = 'simple';
|
|
errBox.classList.add('d-none');
|
|
});
|
|
|
|
document.getElementById('json-save-btn').addEventListener('click', async () => {
|
|
errBox.classList.add('d-none');
|
|
let parsed;
|
|
if (activeTab === 'simple') {
|
|
parsed = readSimpleForm();
|
|
} else {
|
|
try { parsed = JSON.parse(textarea.value); }
|
|
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
|
|
}
|
|
const fd = new FormData();
|
|
fd.append('json_data', JSON.stringify(parsed));
|
|
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
|
|
const result = await resp.json();
|
|
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
|
|
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
|
|
});
|
|
}
|