Files
character-browser/static/js/layout-utils.js
Aodhan Collins 55ff58aba6 Major refactor: deduplicate routes, sync, JS, and fix bugs
- 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>
2026-03-21 23:06:58 +00:00

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'); }
});
}