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:
Aodhan Collins
2026-03-21 23:06:58 +00:00
parent ed9a7b4b11
commit 55ff58aba6
42 changed files with 1493 additions and 3105 deletions

View File

@@ -277,90 +277,12 @@
{% 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 ? '&#9733;' : '&#9734;';
});
});
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 previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewCardHeader.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 if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = '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); progressContainer.classList.add('d-none'); return; }
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
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'); }
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
};
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
jsonEditorUrl: '{{ url_for("save_look_json", slug=look.slug) }}'
});
function showImage(src) {
if (src) document.getElementById('modalImage').src = src;
}
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');
});
</script>
{% endblock %}

View File

@@ -73,8 +73,8 @@
{% set lora_name = look.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ look.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="looks" data-slug="{{ look.slug }}" data-name="{{ look.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="looks" data-slug="{{ look.slug }}" data-name="{{ look.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>