- Migrate 11 character JSONs from old wardrobe keys to _BODY_GROUP_KEYS format - Add is_favourite/is_nsfw columns to Preset model - Add HTTP response validation and timeouts to ComfyUI client - Add path traversal protection on replace cover route - Deduplicate services/mcp.py (4 functions → 2 generic + 2 wrappers) - Extract apply_library_filters() and clean_html_text() shared helpers - Add named constants for 17 ComfyUI workflow node IDs - Fix bare except clauses in services/llm.py - Fix tags schema in ensure_default_outfit() (list → dict) - Convert f-string logging to lazy % formatting - Add 5-minute polling timeout to frontend waitForJob() - Improve migration error handling (non-duplicate errors log at WARNING) - Update CLAUDE.md to reflect all changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
274 lines
12 KiB
JavaScript
274 lines
12 KiB
JavaScript
/**
|
|
* Shared JS for all resource detail pages.
|
|
*
|
|
* Usage: call initDetailPage(options) from the template's <script> block.
|
|
*
|
|
* Options:
|
|
* batchItems — Array of {slug, name} for batch generation (optional)
|
|
* jsonEditorUrl — URL for save_json route (optional, enables JSON editor)
|
|
* hasPreviewGallery — If true, enables addToPreviewGallery helper (default: !!batchItems)
|
|
*/
|
|
function initDetailPage(options = {}) {
|
|
const {
|
|
batchItems = [],
|
|
jsonEditorUrl = null,
|
|
hasPreviewGallery = batchItems.length > 0,
|
|
} = options;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// DOM references
|
|
// -----------------------------------------------------------------------
|
|
const form = document.getElementById('generate-form');
|
|
const progressBar = document.getElementById('progress-bar');
|
|
const progressContainer = document.getElementById('progress-container');
|
|
const progressLabel = document.getElementById('progress-label');
|
|
const previewCard = document.getElementById('preview-card');
|
|
const previewImg = document.getElementById('preview-img');
|
|
const previewPath = document.getElementById('preview-path');
|
|
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
const previewHeader = document.getElementById('preview-card-header');
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Favourite toggle (delegated)
|
|
// -----------------------------------------------------------------------
|
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
const resp = await fetch(btn.dataset.url, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
}
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Preview selection
|
|
// -----------------------------------------------------------------------
|
|
function selectPreview(relativePath, imageUrl) {
|
|
if (!relativePath) return;
|
|
previewImg.src = imageUrl;
|
|
previewPath.value = relativePath;
|
|
replaceBtn.disabled = false;
|
|
previewCard.classList.remove('d-none');
|
|
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
|
previewCard.classList.replace('border-secondary', 'border-success');
|
|
}
|
|
|
|
// Click any image with data-preview-path to select it
|
|
document.addEventListener('click', e => {
|
|
const img = e.target.closest('img[data-preview-path]');
|
|
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Job polling
|
|
// -----------------------------------------------------------------------
|
|
async function waitForJob(jobId, maxPollMs = 300000) {
|
|
return new Promise((resolve, reject) => {
|
|
const start = Date.now();
|
|
const poll = setInterval(async () => {
|
|
if (Date.now() - start > maxPollMs) {
|
|
clearInterval(poll);
|
|
reject(new Error('Job timed out after ' + Math.round(maxPollMs / 1000) + 's'));
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
const data = await resp.json();
|
|
if (data.status === 'done') {
|
|
clearInterval(poll);
|
|
resolve(data);
|
|
} else if (data.status === 'failed' || data.status === 'removed') {
|
|
clearInterval(poll);
|
|
reject(new Error(data.error || 'Job failed'));
|
|
} else if (data.status === 'processing') {
|
|
progressLabel.textContent = 'Generating\u2026';
|
|
} else {
|
|
progressLabel.textContent = 'Queued\u2026';
|
|
}
|
|
} catch (err) { console.error('Poll error:', err); }
|
|
}, 1500);
|
|
});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Preview gallery helper
|
|
// -----------------------------------------------------------------------
|
|
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
|
const gallery = document.getElementById('preview-gallery');
|
|
if (!gallery) return;
|
|
const placeholder = document.getElementById('gallery-empty');
|
|
if (placeholder) placeholder.remove();
|
|
const col = document.createElement('div');
|
|
col.className = 'col';
|
|
col.innerHTML = `<div class="position-relative">
|
|
<img src="${imageUrl}" class="img-fluid rounded"
|
|
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
|
data-preview-path="${relativePath}"
|
|
title="${charName}">
|
|
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
|
</div>`;
|
|
gallery.insertBefore(col, gallery.firstChild);
|
|
const badge = document.querySelector('#previews-tab .badge');
|
|
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
|
|
else {
|
|
const tab = document.getElementById('previews-tab');
|
|
if (tab) tab.insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Form submit handler (single generation)
|
|
// -----------------------------------------------------------------------
|
|
if (form) {
|
|
form.addEventListener('submit', async (e) => {
|
|
const submitter = e.submitter;
|
|
if (!submitter || submitter.value !== 'preview') return;
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(form);
|
|
formData.append('action', 'preview');
|
|
|
|
progressContainer.classList.remove('d-none');
|
|
progressBar.style.width = '100%';
|
|
progressBar.textContent = '';
|
|
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
progressLabel.textContent = 'Queuing\u2026';
|
|
|
|
try {
|
|
const response = await fetch(form.getAttribute('action'), {
|
|
method: 'POST', body: formData,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
const data = await response.json();
|
|
if (data.error) { alert('Error: ' + data.error); return; }
|
|
|
|
progressLabel.textContent = 'Queued\u2026';
|
|
const jobResult = await waitForJob(data.job_id);
|
|
|
|
if (jobResult.result?.image_url) {
|
|
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
|
if (hasPreviewGallery) {
|
|
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
|
}
|
|
}
|
|
if (typeof updateSeedFromResult === 'function') {
|
|
updateSeedFromResult(jobResult.result);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Generation failed: ' + err.message);
|
|
} finally {
|
|
progressContainer.classList.add('d-none');
|
|
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
}
|
|
});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Endless mode callback
|
|
// -----------------------------------------------------------------------
|
|
window._onEndlessResult = function(jobResult) {
|
|
if (jobResult.result?.image_url) {
|
|
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
|
if (hasPreviewGallery) {
|
|
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
|
}
|
|
}
|
|
};
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Batch generation (secondary categories only)
|
|
// -----------------------------------------------------------------------
|
|
if (batchItems.length > 0) {
|
|
let stopBatch = false;
|
|
const generateAllBtn = document.getElementById('generate-all-btn');
|
|
const stopAllBtn = document.getElementById('stop-all-btn');
|
|
const batchProgress = document.getElementById('batch-progress');
|
|
const batchLabel = document.getElementById('batch-label');
|
|
const batchBar = document.getElementById('batch-bar');
|
|
|
|
if (generateAllBtn) {
|
|
generateAllBtn.addEventListener('click', async () => {
|
|
if (batchItems.length === 0) { alert('No characters available.'); return; }
|
|
stopBatch = false;
|
|
generateAllBtn.disabled = true;
|
|
stopAllBtn.classList.remove('d-none');
|
|
batchProgress.classList.remove('d-none');
|
|
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
|
|
|
const genForm = document.getElementById('generate-form');
|
|
const formAction = genForm.getAttribute('action');
|
|
|
|
// Phase 1: submit all jobs
|
|
batchLabel.textContent = 'Queuing all characters\u2026';
|
|
const pending = [];
|
|
for (const char of batchItems) {
|
|
if (stopBatch) break;
|
|
const fd = new FormData();
|
|
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(
|
|
cb => fd.append('include_field', cb.value));
|
|
fd.append('character_slug', char.slug);
|
|
fd.append('action', 'preview');
|
|
try {
|
|
const resp = await fetch(formAction, {
|
|
method: 'POST', body: fd,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.error) pending.push({ char, jobId: data.job_id });
|
|
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
|
}
|
|
|
|
// Phase 2: poll all in parallel
|
|
batchBar.style.width = '0%';
|
|
let done = 0;
|
|
const total = pending.length;
|
|
batchLabel.textContent = `0 / ${total} complete`;
|
|
|
|
await Promise.all(pending.map(({ char, jobId }) =>
|
|
waitForJob(jobId).then(result => {
|
|
done++;
|
|
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
|
batchLabel.textContent = `${done} / ${total} complete`;
|
|
if (result.result?.image_url) {
|
|
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
|
}
|
|
}).catch(err => {
|
|
done++;
|
|
console.error(`Failed for ${char.name}:`, err);
|
|
})
|
|
));
|
|
|
|
batchBar.style.width = '100%';
|
|
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
|
generateAllBtn.disabled = false;
|
|
stopAllBtn.classList.add('d-none');
|
|
setTimeout(() => {
|
|
batchProgress.classList.add('d-none');
|
|
batchBar.style.width = '0%';
|
|
}, 3000);
|
|
});
|
|
}
|
|
|
|
if (stopAllBtn) {
|
|
stopAllBtn.addEventListener('click', () => {
|
|
stopBatch = true;
|
|
stopAllBtn.classList.add('d-none');
|
|
batchLabel.textContent = 'Stopping\u2026';
|
|
});
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// JSON editor
|
|
// -----------------------------------------------------------------------
|
|
if (jsonEditorUrl && typeof initJsonEditor === 'function') {
|
|
initJsonEditor(jsonEditorUrl);
|
|
}
|
|
}
|