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>
This commit is contained in:
@@ -122,6 +122,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %}
|
||||
{% if tags %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||
<div class="card-body">
|
||||
{% if tags.associated_resource %}<span class="badge bg-info">{{ tags.associated_resource }}</span>{% endif %}
|
||||
{% if tags.adetailer_targets %}<span class="badge bg-primary">{{ tags.adetailer_targets | join(', ') }}</span>{% endif %}
|
||||
{% if detailer.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||
{% if detailer.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
@@ -265,188 +278,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/detail-common.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
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 ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
const charSelect = document.getElementById('character_select');
|
||||
const charContext = document.getElementById('character-context');
|
||||
const actionSelect = document.getElementById('action_select');
|
||||
|
||||
charSelect.addEventListener('change', () => {
|
||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const img = e.target.closest('img[data-preview-path]');
|
||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||
});
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
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 progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
||||
} catch (err) { console.error('Poll error:', err); }
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
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…';
|
||||
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…';
|
||||
const jobResult = await waitForJob(data.job_id);
|
||||
if (jobResult.result?.image_url) {
|
||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||
}
|
||||
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);
|
||||
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||
}
|
||||
};
|
||||
|
||||
const allCharacters = [
|
||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
||||
{% endfor %}
|
||||
];
|
||||
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');
|
||||
|
||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||
const gallery = document.getElementById('preview-gallery');
|
||||
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%;"
|
||||
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
|
||||
|
||||
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 document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
|
||||
}
|
||||
|
||||
generateAllBtn.addEventListener('click', async () => {
|
||||
if (allCharacters.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');
|
||||
batchLabel.textContent = 'Queuing all characters…';
|
||||
const pending = [];
|
||||
for (const char of allCharacters) {
|
||||
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_slug', actionSelect.value);
|
||||
fd.append('extra_positive', document.getElementById('extra_positive').value);
|
||||
fd.append('extra_negative', document.getElementById('extra_negative').value);
|
||||
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); }
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
stopAllBtn.addEventListener('click', () => {
|
||||
stopBatch = true;
|
||||
stopAllBtn.classList.add('d-none');
|
||||
batchLabel.textContent = 'Stopping…';
|
||||
});
|
||||
|
||||
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initDetailPage({
|
||||
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
|
||||
jsonEditorUrl: '{{ url_for("save_detailer_json", slug=detailer.slug) }}'
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user