Files
character-browser/templates/gallery.html
Aodhan Collins 5e4348ebc1 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>
2026-03-13 02:07:16 +00:00

779 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "layout.html" %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Image Gallery
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
</h4>
</div>
<!-- Filters -->
<form method="get" action="{{ url_for('gallery') }}" class="mb-3" id="filter-form">
<div class="row g-2 align-items-end">
<!-- Category -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Category</label>
<select name="category" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="all" {% if category == 'all' %}selected{% endif %}>All</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if category == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
{% endfor %}
</select>
</div>
<!-- Item (only when a category is selected) -->
{% if slug_options %}
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Item</label>
<select name="slug" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All items</option>
{% for s, n in slug_options %}
<option value="{{ s }}" {% if slug == s %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" name="slug" value="{{ slug }}">
{% endif %}
<!-- Sort -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Sort</label>
<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>
<!-- Per page -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Per page</label>
<select name="per_page" class="form-select form-select-sm" onchange="this.form.submit()">
{% for n in [24, 48, 96] %}
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<!-- Active filter chips -->
<div class="col">
{% if category != 'all' %}
<span class="badge bg-primary me-1">
{{ category | capitalize }}
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
</span>
{% endif %}
{% if slug %}
<span class="badge bg-secondary me-1">
{{ slug }}
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
</span>
{% endif %}
</div>
<input type="hidden" name="page" value="1">
</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">
Showing {{ (page - 1) * per_page + 1 }}{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}
</p>
{% endif %}
<!-- Grid -->
{% if images %}
<div class="gallery-grid mb-4">
{% set cat_colors = {
'characters': 'primary',
'actions': 'danger',
'outfits': 'success',
'scenes': 'info',
'styles': 'warning',
'detailers': 'secondary',
'checkpoints': 'dark',
} %}
{% for img in images %}
<div class="gallery-card"
data-category="{{ img.category }}"
data-slug="{{ img.slug }}"
data-name="{{ img.item_name }}"
data-path="{{ img.path }}"
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 }})'>
Prompt
</button>
{% if img.category == 'characters' %}
<a href="{{ url_for('detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a>
{% elif img.category == 'checkpoints' %}
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a>
{% else %}
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}"
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"
title="Delete"
onclick='event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})'>
🗑
</button>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav>
<ul class="pagination pagination-sm flex-wrap justify-content-center">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page-1) }}"></a>
</li>
{% set window = 3 %}
{% set ns = namespace(last=0) %}
{% for p in range(1, total_pages + 1) %}
{% if p == 1 or p == total_pages or (p >= page - window and p <= page + window) %}
{% if ns.last and p > ns.last + 1 %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=p) }}">{{ p }}</a>
</li>
{% set ns.last = p %}
{% endif %}
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page+1) }}"></a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<div class="fs-1 mb-2">🖼️</div>
<p>No images found{% if category != 'all' %} in <strong>{{ category }}</strong>{% endif %}{% if slug %} for <strong>{{ slug }}</strong>{% endif %}.</p>
</div>
{% endif %}
<!-- Prompt modal -->
<div class="modal fade" id="promptModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="promptModalTitle">Generation Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="promptLoading" class="text-center py-4">
<div class="spinner-border text-secondary"></div>
</div>
<div id="promptContent" class="d-none">
<!-- Prompts -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-semibold mb-0">Positive Prompt</label>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptPositive', this)">Copy</button>
</div>
<textarea id="promptPositive" class="form-control form-control-sm font-monospace" rows="5" readonly></textarea>
</div>
<div class="mb-3" id="negativeRow">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-semibold mb-0">Negative Prompt</label>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptNegative', this)">Copy</button>
</div>
<textarea id="promptNegative" class="form-control form-control-sm font-monospace" rows="2" readonly></textarea>
</div>
<!-- LoRAs -->
<div class="mb-3" id="lorasRow">
<label class="form-label fw-semibold mb-1">LoRAs</label>
<div id="lorasContainer"></div>
</div>
<!-- Generation params -->
<div class="mb-2" id="paramsRow">
<label class="form-label fw-semibold mb-1">Generation Parameters</label>
<div class="meta-grid" id="metaGrid"></div>
</div>
<div id="noMetaMsg" class="d-none">
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
</div>
</div>
</div>
<div class="modal-footer">
<a id="openGeneratorBtn" href="{{ url_for('generator') }}" class="btn btn-primary">Open in Generator</a>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Image</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-0">Delete <strong id="deleteItemName"></strong>? The image file will be removed.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ---- Prompt modal ----
let promptModal;
document.addEventListener('DOMContentLoaded', () => {
promptModal = new bootstrap.Modal(document.getElementById('promptModal'));
});
async function showPrompt(imgPath, name, category, slug) {
document.getElementById('promptModalTitle').textContent = name;
document.getElementById('promptContent').classList.add('d-none');
document.getElementById('promptLoading').classList.remove('d-none');
promptModal.show();
try {
const res = await fetch(`/gallery/prompt-data?path=${encodeURIComponent(imgPath)}`);
const data = await res.json();
// Positive prompt
document.getElementById('promptPositive').value = data.positive || '';
// Negative prompt
const neg = data.negative || '';
document.getElementById('promptNegative').value = neg;
document.getElementById('negativeRow').style.display = neg ? '' : 'none';
// LoRAs
const loras = (data.loras || []).filter(l => l.name);
const lorasContainer = document.getElementById('lorasContainer');
lorasContainer.innerHTML = '';
if (loras.length) {
loras.forEach(l => {
const chip = document.createElement('span');
chip.className = 'lora-chip';
// Show only the filename part of the path
const shortName = l.name.split('/').pop().replace('.safetensors', '');
chip.innerHTML = `${shortName} <span class="lora-strength">${Number(l.strength).toFixed(2)}</span>`;
chip.title = l.name;
lorasContainer.appendChild(chip);
});
}
document.getElementById('lorasRow').style.display = loras.length ? '' : 'none';
// Generation params
const params = [
['Checkpoint', data.checkpoint ? data.checkpoint.split('/').pop() : null],
['Seed', data.seed],
['Steps', data.steps],
['CFG', data.cfg],
['Sampler', data.sampler],
['Scheduler', data.scheduler],
];
const hasParams = params.some(([, v]) => v !== null && v !== undefined);
const grid = document.getElementById('metaGrid');
grid.innerHTML = '';
params.forEach(([label, val]) => {
if (val === null || val === undefined) return;
const lEl = document.createElement('span'); lEl.className = 'meta-label'; lEl.textContent = label;
const vEl = document.createElement('span'); vEl.className = 'meta-value'; vEl.textContent = val;
grid.appendChild(lEl); grid.appendChild(vEl);
});
document.getElementById('paramsRow').style.display = hasParams ? '' : 'none';
document.getElementById('noMetaMsg').classList.toggle('d-none',
!!(data.positive || loras.length || hasParams));
// Generator link
const genUrl = category === 'characters'
? `/character/${slug}`
: category === 'checkpoints'
? `/checkpoint/${slug}`
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
const genBtn = document.getElementById('openGeneratorBtn');
genBtn.href = genUrl;
genBtn.textContent = (category === 'characters' || category === 'checkpoints') ? 'Open' : 'Open in Generator';
} catch (e) {
document.getElementById('promptPositive').value = 'Error loading metadata.';
} finally {
document.getElementById('promptLoading').classList.add('d-none');
document.getElementById('promptContent').classList.remove('d-none');
}
}
function copyField(id, btn) {
const text = document.getElementById(id).value;
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = orig, 1500);
});
}
// ---- Delete modal ----
let _deletePath = '';
let deleteModal;
document.addEventListener('DOMContentLoaded', () => {
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
});
function openDeleteModal(path, name) {
_deletePath = path;
document.getElementById('deleteItemName').textContent = name;
deleteModal.show();
}
async function confirmDelete() {
deleteModal.hide();
try {
const res = await fetch('/gallery/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: _deletePath}),
});
const data = await res.json();
if (data.status === 'ok') {
const card = document.querySelector(`.gallery-card[data-path="${CSS.escape(_deletePath)}"]`);
if (card) card.remove();
const countEl = document.querySelector('h4 .text-muted');
if (countEl) {
const m = countEl.textContent.match(/(\d+)/);
if (m) {
const n = parseInt(m[1]) - 1;
countEl.textContent = ` ${n} image${n !== 1 ? 's' : ''}`;
}
}
} else {
alert('Delete failed: ' + (data.error || 'unknown error'));
}
} catch (e) {
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 %}