Add extra prompts, endless generation, random character default, and small fixes

- Add extra positive/negative prompt textareas to all 9 detail pages with session persistence
- Add Endless generation button to all detail pages (continuous preview generation until stopped)
- Default character selector to "Random Character" on all secondary detail pages
- Fix queue clear endpoint (remove spurious auth check)
- Refactor app.py into routes/ and services/ modules
- Update CLAUDE.md with new architecture documentation
- Various data file updates and cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-13 02:07:16 +00:00
parent 1b8a798c31
commit 5e4348ebc1
170 changed files with 17367 additions and 9781 deletions

View File

@@ -43,6 +43,7 @@
<select name="sort" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Newest first</option>
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Oldest first</option>
<option value="random" {% if sort == 'random' %}selected{% endif %}>🎲 Random</option>
</select>
</div>
@@ -76,6 +77,133 @@
</div>
</form>
<!-- Quick Resource Filters -->
<div class="mb-3">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="text-muted small me-2">Quick filters:</span>
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'all' %}bg-secondary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
All
</a>
<a href="{{ url_for('gallery', category='characters', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'characters' %}bg-primary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Characters
</a>
<a href="{{ url_for('gallery', category='looks', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'looks' %}bg-primary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Looks
</a>
<a href="{{ url_for('gallery', category='outfits', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'outfits' %}bg-success{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Outfits
</a>
<a href="{{ url_for('gallery', category='actions', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'actions' %}bg-danger{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Actions
</a>
<a href="{{ url_for('gallery', category='scenes', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'scenes' %}bg-info{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Scenes
</a>
<a href="{{ url_for('gallery', category='styles', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'styles' %}bg-warning{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Styles
</a>
<a href="{{ url_for('gallery', category='detailers', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'detailers' %}bg-secondary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Detailers
</a>
<a href="{{ url_for('gallery', category='checkpoints', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'checkpoints' %}bg-dark{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Checkpoints
</a>
</div>
</div>
<!-- Gallery View Mode Controls -->
<div class="gallery-controls" id="gallery-controls">
<div class="gallery-view-modes">
<button class="gallery-view-btn active" data-mode="grid" title="Info view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Info
</button>
<button class="gallery-view-btn" data-mode="mosaic" title="Gallery view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"></rect>
<rect x="11" y="3" width="6" height="6"></rect>
<rect x="3" y="11" width="6" height="6"></rect>
<rect x="11" y="11" width="6" height="6"></rect>
</svg>
Gallery
</button>
</div>
<div class="gallery-selection-controls">
<button class="gallery-view-btn" id="selection-mode-toggle" title="Multi-select mode">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<path d="M9 11l3 3 6-6"></path>
</svg>
Select
</button>
<button class="btn btn-sm btn-danger d-none" id="delete-selected-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="me-1">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
<div class="gallery-slideshow-dropdown">
<button class="gallery-slideshow-btn" id="slideshow-menu-btn" aria-haspopup="true" aria-expanded="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
Slideshow ▾
</button>
<div class="gallery-slideshow-menu" id="slideshow-menu">
<div class="gallery-slideshow-menu-item" data-mode="cinema">
<span>🎬</span>
<div>
<div class="fw-medium">Cinema</div>
<small class="text-muted">Ambient glow & Ken Burns</small>
</div>
</div>
<div class="gallery-slideshow-menu-item" data-mode="classic">
<span></span>
<div>
<div class="fw-medium">Classic</div>
<small class="text-muted">Clean transitions</small>
</div>
</div>
<div class="gallery-slideshow-menu-item" data-mode="showcase">
<span>🖼</span>
<div>
<div class="fw-medium">Showcase</div>
<small class="text-muted">Digital frame style</small>
</div>
</div>
<div class="gallery-slideshow-menu-item" data-mode="ambient">
<span>🌌</span>
<div>
<div class="fw-medium">Ambient</div>
<small class="text-muted">Screensaver with particles</small>
</div>
</div>
</div>
</div>
<div class="ms-auto small text-muted d-none d-md-block">
<kbd>G</kbd> Info · <kbd>A</kbd> Gallery · <kbd>S</kbd> Slideshow
</div>
</div>
<!-- Showing XY of N -->
{% if total > 0 %}
<p class="text-muted small mb-2">
@@ -101,19 +229,47 @@
data-slug="{{ img.slug }}"
data-name="{{ img.item_name }}"
data-path="{{ img.path }}"
data-src="{{ url_for('static', filename='uploads/' + img.path) }}"
onclick="openLightbox(this)">
data-src="{{ url_for('static', filename='uploads/' + img.path) }}">
<input type="checkbox" class="gallery-card-checkbox d-none" data-path="{{ img.path }}" onclick="event.stopPropagation()">
<img src="{{ url_for('static', filename='uploads/' + img.path) }}"
alt="{{ img.item_name }}"
loading="lazy">
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
</span>
<!-- Info View Additional Metadata -->
<div class="info-meta">
{% if img.meta %}
{% if img.meta.checkpoint %}
<span class="badge bg-dark mb-1 d-inline-block text-truncate w-100" style="font-size: 0.65rem;" title="{{ img.meta.checkpoint }}">{{ img.meta.checkpoint.split('/')[-1] }}</span>
{% endif %}
<div class="d-flex flex-wrap gap-1">
{% for lora in img.meta.loras %}
{% set lora_name = lora.name.split('/')[-1].replace('.safetensors', '') %}
{% set subfolder = lora.name.split('/')[1] if lora.name.startswith('Illustrious/') else '' %}
{% if subfolder == 'Characters' %}{% set color = 'primary' %}
{% elif subfolder == 'Looks' %}{% set color = 'primary' %}
{% elif subfolder == 'Clothing' %}{% set color = 'success' %}
{% elif subfolder == 'Actions' %}{% set color = 'danger' %}
{% elif subfolder == 'Scenes' %}{% set color = 'info' %}
{% elif subfolder == 'Styles' %}{% set color = 'warning' %}
{% elif subfolder == 'Detailers' %}{% set color = 'secondary' %}
{% else %}{% set color = 'light text-dark' %}
{% endif %}
<span class="badge bg-{{ color }}" style="font-size: 0.6rem;" title="{{ lora.name }}">{{ lora_name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="overlay">
<div class="text-white small fw-semibold text-truncate">{{ img.item_name }}</div>
<div class="d-flex gap-1 mt-1">
<button class="btn btn-sm btn-light py-0 px-2 flex-grow-1"
onclick="event.stopPropagation(); showPrompt({{ img.path | tojson }}, {{ img.item_name | tojson }}, {{ img.category | tojson }}, {{ img.slug | tojson }})">
onclick='event.stopPropagation(); showPrompt({{ img.path | tojson }}, {{ img.item_name | tojson }}, {{ img.category | tojson }}, {{ img.slug | tojson }})'>
Prompt
</button>
{% if img.category == 'characters' %}
@@ -131,7 +287,7 @@
{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-2"
title="Delete"
onclick="event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})">
onclick='event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})'>
🗑
</button>
</div>
@@ -176,19 +332,6 @@
</div>
{% endif %}
<!-- Lightbox -->
<div id="lightbox" onclick="closeLightbox()">
<span id="lightbox-close" onclick="closeLightbox()">×</span>
<div id="lightbox-img-wrap" onclick="event.stopPropagation()">
<img id="lightbox-img" src="" alt="" onclick="openFullSize()">
<div id="lightbox-meta"></div>
<div id="lightbox-hint">Click image to open full size · Esc to close</div>
</div>
<div id="lightbox-actions" onclick="event.stopPropagation()">
<button class="btn btn-sm btn-light" onclick="lightboxShowPrompt()">View Prompt</button>
<button class="btn btn-sm btn-outline-danger" onclick="openDeleteModal(_lightboxPath, _lightboxName); closeLightbox()">Delete</button>
</div>
</div>
<!-- Prompt modal -->
<div class="modal fade" id="promptModal" tabindex="-1">
@@ -267,38 +410,6 @@
{% block scripts %}
<script>
// ---- Lightbox state ----
let _lightboxSrc = '';
let _lightboxPath = '';
let _lightboxCategory = '';
let _lightboxSlug = '';
let _lightboxName = '';
function openLightbox(card) {
_lightboxSrc = card.dataset.src;
_lightboxPath = card.dataset.path;
_lightboxCategory = card.dataset.category;
_lightboxSlug = card.dataset.slug;
_lightboxName = card.dataset.name;
document.getElementById('lightbox-img').src = _lightboxSrc;
document.getElementById('lightbox-meta').textContent = _lightboxName + ' · ' + _lightboxCategory;
document.getElementById('lightbox').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
document.body.style.overflow = '';
}
function openFullSize() {
window.open(_lightboxSrc, '_blank');
}
function lightboxShowPrompt() {
closeLightbox();
showPrompt(_lightboxPath, _lightboxName, _lightboxCategory, _lightboxSlug);
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
// ---- Prompt modal ----
let promptModal;
document.addEventListener('DOMContentLoaded', () => {
@@ -428,5 +539,240 @@ async function confirmDelete() {
alert('Delete failed: ' + e);
}
}
// ============================================================
// GAZE Gallery Enhancement - View Mode Controls
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
// Initialize GalleryCore if available
if (window.GalleryCore) {
GalleryCore.init('.gallery-grid');
console.log('GalleryCore initialized');
}
// View mode buttons
const viewButtons = document.querySelectorAll('.gallery-view-btn');
viewButtons.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
// Update active state
viewButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Apply layout
if (window.GalleryCore) {
GalleryCore.setLayout(mode);
}
// Save preference
localStorage.setItem('gaze-gallery-view', mode);
});
});
// Restore saved view mode
const savedMode = localStorage.getItem('gaze-gallery-view');
if (savedMode) {
const btn = document.querySelector(`.gallery-view-btn[data-mode="${savedMode}"]`);
if (btn) {
viewButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
}
// Slideshow dropdown
const slideshowBtn = document.getElementById('slideshow-menu-btn');
const slideshowMenu = document.getElementById('slideshow-menu');
if (slideshowBtn && slideshowMenu) {
slideshowBtn.addEventListener('click', (e) => {
e.stopPropagation();
slideshowMenu.classList.toggle('open');
slideshowBtn.setAttribute('aria-expanded', slideshowMenu.classList.contains('open'));
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!slideshowMenu.contains(e.target) && e.target !== slideshowBtn) {
slideshowMenu.classList.remove('open');
slideshowBtn.setAttribute('aria-expanded', 'false');
}
});
// Slideshow mode selection
slideshowMenu.querySelectorAll('.gallery-slideshow-menu-item').forEach(item => {
item.addEventListener('click', () => {
const mode = item.dataset.mode;
slideshowMenu.classList.remove('open');
slideshowBtn.setAttribute('aria-expanded', 'false');
// Start slideshow
if (window.GalleryCore) {
GalleryCore.startSlideshow(mode);
}
});
});
}
// ============================================================
// Multi-Selection Mode
// ============================================================
let selectionMode = false;
const selectedPaths = new Set();
const selectionToggleBtn = document.getElementById('selection-mode-toggle');
const deleteSelectedBtn = document.getElementById('delete-selected-btn');
const selectedCountEl = document.getElementById('selected-count');
const allCheckboxes = document.querySelectorAll('.gallery-card-checkbox');
const galleryGrid = document.querySelector('.gallery-grid');
// Toggle selection mode
selectionToggleBtn.addEventListener('click', () => {
selectionMode = !selectionMode;
if (selectionMode) {
// Enter selection mode
selectionToggleBtn.classList.add('active');
galleryGrid.classList.add('selection-mode');
allCheckboxes.forEach(cb => cb.classList.remove('d-none'));
// Modify card click behavior
document.querySelectorAll('.gallery-card').forEach(card => {
card.style.cursor = 'pointer';
card.onclick = function(e) {
e.stopPropagation();
e.preventDefault();
// In selection mode, clicking ANYWHERE on the card toggles checkbox
const checkbox = this.querySelector('.gallery-card-checkbox');
checkbox.checked = !checkbox.checked;
handleCheckboxChange(checkbox);
};
});
} else {
// Exit selection mode
selectionToggleBtn.classList.remove('active');
galleryGrid.classList.remove('selection-mode');
allCheckboxes.forEach(cb => {
cb.classList.add('d-none');
cb.checked = false;
});
deleteSelectedBtn.classList.add('d-none');
selectedPaths.clear();
// Restore original click behavior
document.querySelectorAll('.gallery-card').forEach(card => {
card.style.cursor = '';
card.onclick = null;
});
}
});
// Handle checkbox changes
function handleCheckboxChange(checkbox) {
const path = checkbox.dataset.path;
if (checkbox.checked) {
selectedPaths.add(path);
checkbox.closest('.gallery-card').classList.add('selected');
} else {
selectedPaths.delete(path);
checkbox.closest('.gallery-card').classList.remove('selected');
}
// Update UI
selectedCountEl.textContent = selectedPaths.size;
if (selectedPaths.size > 0) {
deleteSelectedBtn.classList.remove('d-none');
} else {
deleteSelectedBtn.classList.add('d-none');
}
}
// Attach checkbox listeners
allCheckboxes.forEach(cb => {
cb.addEventListener('change', () => handleCheckboxChange(cb));
});
// Delete selected images
deleteSelectedBtn.addEventListener('click', async () => {
if (selectedPaths.size === 0) return;
const count = selectedPaths.size;
if (!confirm(`Delete ${count} selected image${count !== 1 ? 's' : ''}? This cannot be undone.`)) {
return;
}
// Show loading state
deleteSelectedBtn.disabled = true;
deleteSelectedBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span> Deleting...`;
// Delete all selected images
const pathsToDelete = Array.from(selectedPaths);
let successCount = 0;
let failCount = 0;
for (const path of pathsToDelete) {
try {
const res = await fetch('/gallery/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path}),
});
const data = await res.json();
if (data.status === 'ok') {
// Remove card from DOM
const card = document.querySelector(`.gallery-card[data-path="${CSS.escape(path)}"]`);
if (card) card.remove();
selectedPaths.delete(path);
successCount++;
} else {
failCount++;
}
} catch (e) {
console.error(`Failed to delete ${path}:`, e);
failCount++;
}
}
// Update count in header
const countEl = document.querySelector('h4 .text-muted');
if (countEl) {
const m = countEl.textContent.match(/(\d+)/);
if (m) {
const n = parseInt(m[1]) - successCount;
countEl.textContent = ` ${n} image${n !== 1 ? 's' : ''}`;
}
}
// Reset button state
deleteSelectedBtn.disabled = false;
deleteSelectedBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="me-1">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete Selected (<span id="selected-count">0</span>)
`;
// Update selected count reference
selectedCountEl = document.getElementById('selected-count');
selectedCountEl.textContent = selectedPaths.size;
if (selectedPaths.size === 0) {
deleteSelectedBtn.classList.add('d-none');
}
// Show result message
if (successCount > 0) {
window.location.reload();
} else if (failCount > 0) {
alert(`Failed to delete ${failCount} image${failCount !== 1 ? 's' : ''}`);
}
});
});
</script>
<!-- Load Gallery Enhancement Script -->
<script src="{{ url_for('static', filename='js/gallery/gallery-core.js') }}"></script>
{% endblock %}