Replaces old list-format tags (which duplicated prompt content) with structured dict tags per category (origin_series, outfit_type, participants, style_type, scene_type, etc.). Tags are now purely organizational metadata — removed from the prompt pipeline entirely. Adds is_favourite and is_nsfw columns to all 8 resource models. Favourite is DB-only (user preference); NSFW is mirrored in JSON tags for rescan persistence. All library pages get filter controls and favourites-first sorting. Introduces a parallel LLM job queue (_enqueue_task + _llm_queue_worker) for background tag regeneration, with the same status polling UI as ComfyUI jobs. Fixes call_llm() to use has_request_context() fallback for background threads. Adds global search (/search) across resources and gallery images, with navbar search bar. Adds gallery image sidecar JSON for per-image favourite/NSFW metadata. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
896 lines
36 KiB
HTML
896 lines
36 KiB
HTML
{% 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>
|
||
|
||
<!-- Favourite filter -->
|
||
<div class="col-auto">
|
||
<label class="form-label form-label-sm mb-1"> </label>
|
||
<div class="form-check mt-1">
|
||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NSFW filter -->
|
||
<div class="col-auto">
|
||
<label class="form-label form-label-sm mb-1">Rating</label>
|
||
<select name="nsfw" class="form-select form-select-sm" onchange="this.form.submit()">
|
||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All</option>
|
||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||
</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, xref_category=xref_category, xref_slug=xref_slug) }}" 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, xref_category=xref_category, xref_slug=xref_slug) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||
</span>
|
||
{% endif %}
|
||
{% if xref_category and xref_slug %}
|
||
<span class="badge bg-info me-1">
|
||
Cross-ref: {{ xref_category | capitalize }} = {{ xref_slug }}
|
||
<a href="{{ url_for('gallery', category=category, slug=slug, 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">
|
||
{% if xref_category %}<input type="hidden" name="xref_category" value="{{ xref_category }}">{% endif %}
|
||
{% if xref_slug %}<input type="hidden" name="xref_slug" value="{{ xref_slug }}">{% endif %}
|
||
</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>
|
||
<span class="text-muted small mx-1">|</span>
|
||
<a href="{{ url_for('gallery', category='presets', sort=sort, per_page=per_page) }}"
|
||
class="badge {% if category == 'presets' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
|
||
{% if category == 'presets' %}style="background-color: #6f42c1"{% endif %}>
|
||
Presets
|
||
</a>
|
||
<a href="{{ url_for('gallery', category='generator', sort=sort, per_page=per_page) }}"
|
||
class="badge {% if category == 'generator' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
|
||
{% if category == 'generator' %}style="background-color: #20c997"{% endif %}>
|
||
Generator
|
||
</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 X–Y 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',
|
||
'looks': 'primary',
|
||
'presets': 'purple',
|
||
'generator': 'teal',
|
||
} %}
|
||
{% 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>
|
||
{% if img._sidecar.get('is_favourite') %}<span class="fav-badge" title="Favourite" onclick="event.stopPropagation(); toggleImageFavourite('{{ img.path }}', this)">★</span>{% else %}<span class="fav-badge fav-off" title="Mark as favourite" onclick="event.stopPropagation(); toggleImageFavourite('{{ img.path }}', this)">☆</span>{% endif %}
|
||
{% if img._sidecar.get('is_nsfw') %}<span class="nsfw-badge badge bg-danger" style="position:absolute;top:4px;right:4px;font-size:0.6rem;">NSFW</span>{% endif %}
|
||
|
||
<!-- 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>
|
||
{% elif img.category in ('presets', 'generator') %}
|
||
<a href="{{ url_for('preset_detail', slug=img.slug) }}"
|
||
class="btn btn-sm btn-outline-light py-0 px-2"
|
||
onclick="event.stopPropagation()">Preset</a>
|
||
{% else %}
|
||
<a href="{{ url_for('generator') }}?preset={{ 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>
|
||
|
||
<!-- Cross-reference entity chips (from sidecar) -->
|
||
<div class="mb-3 d-none" id="xrefRow">
|
||
<label class="form-label fw-semibold mb-1">Used Entities</label>
|
||
<div class="d-flex flex-wrap gap-1" id="xrefContainer"></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 %}
|
||
<style>
|
||
.fav-badge { position: absolute; top: 4px; left: 4px; font-size: 1.2rem; color: #ffc107; cursor: pointer; text-shadow: 0 0 3px rgba(0,0,0,0.7); z-index: 2; }
|
||
.fav-badge.fav-off { color: rgba(255,255,255,0.5); }
|
||
.fav-badge:hover { transform: scale(1.2); }
|
||
</style>
|
||
<script>
|
||
async function toggleImageFavourite(path, el) {
|
||
const resp = await fetch('/gallery/image/favourite', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({path})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
el.innerHTML = data.is_favourite ? '★' : '☆';
|
||
el.classList.toggle('fav-off', !data.is_favourite);
|
||
}
|
||
}
|
||
async function toggleImageNsfw(path, el) {
|
||
const resp = await fetch('/gallery/image/nsfw', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({path})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) location.reload();
|
||
}
|
||
// ---- 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));
|
||
|
||
// Cross-reference entity chips (from sidecar)
|
||
const xrefContainer = document.getElementById('xrefContainer');
|
||
const xrefRow = document.getElementById('xrefRow');
|
||
xrefContainer.innerHTML = '';
|
||
if (data.sidecar) {
|
||
const sc = data.sidecar;
|
||
const xrefColors = {
|
||
character: 'primary', outfit: 'success', action: 'danger',
|
||
style: 'warning', scene: 'info', detailer: 'secondary',
|
||
look: 'primary', preset: 'purple'
|
||
};
|
||
for (const [key, sidecarKey] of [
|
||
['character', 'character_slug'], ['outfit', 'outfit_slug'],
|
||
['action', 'action_slug'], ['style', 'style_slug'],
|
||
['scene', 'scene_slug'], ['detailer', 'detailer_slug'],
|
||
['look', 'look_slug'], ['preset', 'preset_slug']
|
||
]) {
|
||
const val = sc[sidecarKey];
|
||
if (!val) continue;
|
||
const chip = document.createElement('a');
|
||
chip.className = `badge bg-${xrefColors[key] || 'secondary'} text-decoration-none`;
|
||
chip.href = `/gallery?xref_category=${key}&xref_slug=${encodeURIComponent(val)}`;
|
||
chip.textContent = `${key}: ${sc[sidecarKey.replace('_slug', '_name')] || val}`;
|
||
chip.title = `Show all images using this ${key}`;
|
||
xrefContainer.appendChild(chip);
|
||
}
|
||
xrefRow.classList.toggle('d-none', xrefContainer.children.length === 0);
|
||
} else {
|
||
xrefRow.classList.add('d-none');
|
||
}
|
||
|
||
// Generator link
|
||
let genUrl, genLabel;
|
||
if (category === 'characters') {
|
||
genUrl = `/character/${slug}`;
|
||
genLabel = 'Open';
|
||
} else if (category === 'checkpoints') {
|
||
genUrl = `/checkpoint/${slug}`;
|
||
genLabel = 'Open';
|
||
} else if (category === 'presets' || category === 'generator') {
|
||
genUrl = `/generator?preset=${encodeURIComponent(slug)}`;
|
||
genLabel = 'Open in Generator';
|
||
} else {
|
||
genUrl = `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
|
||
genLabel = 'Open in Generator';
|
||
}
|
||
const genBtn = document.getElementById('openGeneratorBtn');
|
||
genBtn.href = genUrl;
|
||
genBtn.textContent = genLabel;
|
||
} 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 %}
|