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

@@ -294,192 +294,20 @@
{% 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 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');
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 preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
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);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
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', '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_action_json", slug=action.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_action_json", slug=action.slug) }}'
});
// Character-context toggle (action-specific)
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
});
</script>
{% endblock %}

View File

@@ -71,8 +71,8 @@
{% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ action.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="actions" data-slug="{{ action.slug }}" data-name="{{ action.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="actions" data-slug="{{ action.slug }}" data-name="{{ action.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -101,6 +101,19 @@
</div>
</div>
</div>
{% set tags = ckpt.data.tags if ckpt.data is mapping and ckpt.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.art_style %}<span class="badge bg-info">{{ tags.art_style }}</span>{% endif %}
{% if tags.base_model %}<span class="badge bg-primary">{{ tags.base_model }}</span>{% endif %}
{% if ckpt.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if ckpt.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -235,184 +248,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 ? '&#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) {}
}, 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'); }
});
// Batch: Generate All Characters
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 preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
title="${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);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
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();
// Phase 1: submit all jobs immediately
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const fd = new FormData();
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(form.getAttribute('action'), {
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
batchLabel.textContent = `0 / ${pending.length} complete`;
let done = 0;
const total = pending.length;
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 after current submissions...';
});
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, 'Endless');
}
};
// JSON Editor
initJsonEditor('{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}'
});
});
</script>
{% endblock %}

View File

@@ -57,8 +57,8 @@
</div>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
<button class="btn btn-sm btn-outline-danger py-0 px-1 ms-1 resource-delete-btn" title="Delete"
data-category="checkpoints" data-slug="{{ ckpt.slug }}" data-name="{{ ckpt.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 ms-1 resource-delete-btn" title="Delete"
data-category="checkpoints" data-slug="{{ ckpt.slug }}" data-name="{{ ckpt.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -271,114 +271,10 @@
{% 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 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');
let currentJobId = null;
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');
}
// Clicking any image with data-preview-path selects it into the preview pane
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); return; }
currentJobId = data.job_id;
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
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');
}
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
}
};
});
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({});
});
</script>
{% endblock %}

View File

@@ -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">&#9733; 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 ? '&#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 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 %}

View File

@@ -73,8 +73,8 @@
{% set lora_name = detailer.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ detailer.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="detailers" data-slug="{{ detailer.slug }}" data-name="{{ detailer.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="detailers" data-slug="{{ detailer.slug }}" data-name="{{ detailer.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -332,10 +332,10 @@
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Generator</a>
{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-2"
<button class="btn btn-sm btn-danger py-0 px-2"
title="Delete"
onclick='event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})'>
🗑
<img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;">
</button>
</div>
</div>

View File

@@ -84,8 +84,8 @@
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ char.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="characters" data-slug="{{ char.slug }}" data-name="{{ char.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="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -217,192 +217,8 @@
document.getElementById('rdm-name').textContent = name;
resourceDeleteModal.show();
}
async function confirmResourceDelete(mode) {
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);
}
}
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…';
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;
});
}
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 — ' + 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'); }
});
}
</script>
<script src="/static/js/layout-utils.js"></script>
<script>
// ---- Service status indicators ----
(function () {

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>

View File

@@ -275,209 +275,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 ? '&#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 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');
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');
}
// Clicking any image with data-preview-path selects it into the preview pane
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); 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, '');
}
};
// Batch: Generate All Characters
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');
// Phase 1: submit all jobs immediately
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', '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);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping…';
});
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_outfit_json", slug=outfit.slug) }}'
});
});
</script>
{% endblock %}

View File

@@ -74,8 +74,8 @@
{% set lora_name = outfit.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ outfit.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="outfits" data-slug="{{ outfit.slug }}" data-name="{{ outfit.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="outfits" data-slug="{{ outfit.slug }}" data-name="{{ outfit.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -113,20 +113,19 @@
</div>
<!-- Selected Preview -->
<div class="card mb-3" id="preview-pane" {% if not preview_path %}style="display:none"{% endif %}>
<div class="card-header d-flex justify-content-between align-items-center py-1">
<small class="fw-semibold">Selected Preview</small>
</div>
<div class="card-body p-1">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}"
class="img-fluid rounded" alt="Preview">
</div>
<div class="card-footer p-2">
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post">
<div class="card mb-4 {% if preview_path %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_path %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post" class="m-0" id="replace-cover-form">
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_path or '' }}">
<button type="submit" class="btn btn-sm btn-warning w-100">Set as Cover</button>
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_path %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
</div>
@@ -304,119 +303,12 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
// Job polling
let currentJobId = null;
document.getElementById('generate-form').addEventListener('submit', function(e) {
e.preventDefault();
const btn = e.submitter;
const actionVal = btn.value;
const formData = new FormData(this);
formData.set('action', actionVal);
btn.disabled = true;
btn.textContent = 'Generating...';
fetch(this.getAttribute('action'), {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData
})
.then(r => r.json())
.then(data => {
if (data.job_id) {
currentJobId = data.job_id;
pollJob(currentJobId, btn, actionVal);
} else {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Generate Preview';
alert('Error: ' + (data.error || 'Unknown error'));
}
})
.catch(() => { btn.disabled = false; });
});
function pollJob(jobId, btn, actionVal) {
fetch('/api/queue/' + jobId + '/status')
.then(r => r.json())
.then(data => {
if (data.status === 'done' && data.result) {
btn.disabled = false;
btn.textContent = btn.getAttribute('data-orig') || btn.textContent.replace('Generating...', 'Generate Preview');
// Add to gallery
const img = document.createElement('img');
img.src = data.result.image_url;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.dataset.previewPath = data.result.relative_path;
img.addEventListener('click', () => selectPreview(data.result.relative_path, img.src));
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
selectPreview(data.result.relative_path, data.result.image_url);
updateSeedFromResult(data.result);
} else if (data.status === 'failed') {
btn.disabled = false;
btn.textContent = 'Generate Preview';
alert('Generation failed: ' + (data.error || 'Unknown error'));
} else {
setTimeout(() => pollJob(jobId, btn, actionVal), 1500);
}
})
.catch(() => setTimeout(() => pollJob(jobId, btn, actionVal), 3000));
}
function selectPreview(relativePath, imageUrl) {
document.getElementById('preview-path').value = relativePath;
document.getElementById('preview-img').src = imageUrl;
document.getElementById('preview-pane').style.display = '';
}
function showImage(src) {
if (src) document.getElementById('modalImage').src = src;
}
// Delegate click on generated images
document.addEventListener('click', function(e) {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
const img = document.createElement('img');
img.src = jobResult.result.image_url;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.dataset.previewPath = jobResult.result.relative_path;
img.addEventListener('click', () => selectPreview(jobResult.result.relative_path, img.src));
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
}
};
// Resolution preset buttons
document.querySelectorAll('.res-preset').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.res-preset').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
jsonEditorUrl: "{{ url_for('save_preset_json', slug=preset.slug) }}"
});
});
// JSON editor
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
</script>
{% endblock %}

View File

@@ -111,6 +111,18 @@
</div>
</div>
</div>
{% set tags = scene.data.tags if scene.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.scene_type %}<span class="badge bg-info">{{ tags.scene_type }}</span>{% endif %}
{% if scene.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if scene.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -271,192 +283,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 ? '&#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 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');
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 preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
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);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
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', '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_scene_json", slug=scene.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_scene_json", slug=scene.slug) }}'
});
});
</script>
{% endblock %}

View File

@@ -71,8 +71,8 @@
{% set lora_name = scene.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ scene.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="scenes" data-slug="{{ scene.slug }}" data-name="{{ scene.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="scenes" data-slug="{{ scene.slug }}" data-name="{{ scene.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -111,6 +111,18 @@
</div>
</div>
</div>
{% set tags = style.data.tags if style.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.style_type %}<span class="badge bg-info">{{ tags.style_type }}</span>{% endif %}
{% if style.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if style.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -263,185 +275,20 @@
{% 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 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');
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', '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_style_json", slug=style.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_style_json", slug=style.slug) }}'
});
// Character-context toggle (style-specific)
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
});
</script>
{% endblock %}

View File

@@ -71,8 +71,8 @@
{% set lora_name = style.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ style.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="styles" data-slug="{{ style.slug }}" data-name="{{ style.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="styles" data-slug="{{ style.slug }}" data-name="{{ style.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>