ui change to improve tag list load

This commit is contained in:
Bryan Van Deusen
2026-01-24 12:14:27 -05:00
parent 9ebeeed133
commit 5af1dcbd4f
5 changed files with 253 additions and 76 deletions
+66 -22
View File
@@ -423,27 +423,22 @@ def gallery_jump():
) )
@main.route('/tags') def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
def tag_list():
""" """
Generic tag explorer: Helper function to get tags with counts and preview images.
/tags -> all tags Returns (tag_data list, total_count, has_more).
/tags?kind=user -> only user tags
/tags?kind=artist -> only artist tags
/tags?kind=archive -> only archive tags
/tags?kind=character -> only character tags
/tags?kind=series -> only series tags
/tags?kind=rating -> only rating tags
Shows a few preview images per tag.
Optimized to use 2 queries instead of N+1: Optimized to use 2 queries instead of N+1:
1. Get all tags with counts 1. Get tags with counts (paginated)
2. Get preview images for all tags at once using window function 2. Get preview images for those tags using window function
""" """
images_per_tag = 3 # Query for total count
kind = request.args.get('kind') count_q = db.session.query(func.count(Tag.id))
if kind:
count_q = count_q.filter(Tag.kind == kind)
total_count = count_q.scalar()
# Query 1: Get tags with image counts # Query 1: Get tags with image counts (paginated)
q = db.session.query( q = db.session.query(
Tag, Tag,
func.count(image_tags.c.image_id).label('img_count') func.count(image_tags.c.image_id).label('img_count')
@@ -452,7 +447,7 @@ def tag_list():
if kind: if kind:
q = q.filter(Tag.kind == kind) q = q.filter(Tag.kind == kind)
tags_with_counts = q.order_by(Tag.name.asc()).all() tags_with_counts = q.order_by(Tag.name.asc()).offset(offset).limit(limit).all()
# Build tag_id list and initialize tag_data dict # Build tag_id list and initialize tag_data dict
tag_ids = [t.id for t, _ in tags_with_counts] tag_ids = [t.id for t, _ in tags_with_counts]
@@ -469,9 +464,7 @@ def tag_list():
} }
# Query 2: Get preview images for all tags at once using a subquery with row_number # Query 2: Get preview images for all tags at once using a subquery with row_number
# This fetches the top N images per tag in a single query
if tag_ids: if tag_ids:
# Subquery to rank images within each tag
row_num = func.row_number().over( row_num = func.row_number().over(
partition_by=image_tags.c.tag_id, partition_by=image_tags.c.tag_id,
order_by=desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) order_by=desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
@@ -488,7 +481,6 @@ def tag_list():
.subquery() .subquery()
) )
# Get only the top N images per tag
preview_rows = ( preview_rows = (
db.session.query(subq.c.tag_id, ImageRecord) db.session.query(subq.c.tag_id, ImageRecord)
.join(ImageRecord, ImageRecord.id == subq.c.image_id) .join(ImageRecord, ImageRecord.id == subq.c.image_id)
@@ -496,18 +488,70 @@ def tag_list():
.all() .all()
) )
# Group images by tag_id
for tag_id, img in preview_rows: for tag_id, img in preview_rows:
if tag_id in tag_map: if tag_id in tag_map:
tag_map[tag_id]["images"].append(img) tag_map[tag_id]["images"].append(img)
# Convert to list maintaining original order # Convert to list maintaining original order
tag_data = [tag_map[t.id] for t, _ in tags_with_counts] tag_data = [tag_map[t.id] for t, _ in tags_with_counts]
has_more = (offset + len(tag_data)) < total_count
return tag_data, total_count, has_more
@main.route('/tags')
def tag_list():
"""
Generic tag explorer with infinite scroll:
/tags -> all tags
/tags?kind=user -> only user tags
etc.
Shows a few preview images per tag.
Initially loads 35 tags, more loaded via API as user scrolls.
"""
kind = request.args.get('kind')
limit = 35
images_per_tag = 3
tag_data, total_count, has_more = _get_tags_with_previews(
kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag
)
return render_template('tags_list.html', return render_template('tags_list.html',
tag_data=tag_data, tag_data=tag_data,
images_per_tag=images_per_tag, images_per_tag=images_per_tag,
active_kind=kind) active_kind=kind,
total_count=total_count,
has_more=has_more,
page_size=limit)
@main.route('/api/tags/list')
def api_tags_list():
"""
API endpoint for loading more tags (infinite scroll).
Returns JSON with tag cards HTML and pagination info.
"""
kind = request.args.get('kind')
offset = request.args.get('offset', 0, type=int)
limit = request.args.get('limit', 35, type=int)
images_per_tag = 3
tag_data, total_count, has_more = _get_tags_with_previews(
kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag
)
# Render tag cards to HTML
cards_html = render_template('_tag_cards.html', tag_data=tag_data)
return jsonify(
ok=True,
html=cards_html,
count=len(tag_data),
total=total_count,
has_more=has_more,
offset=offset + len(tag_data)
)
@main.route('/artists') @main.route('/artists')
def artist_list(): def artist_list():
+21 -20
View File
@@ -13,30 +13,31 @@ document.addEventListener('DOMContentLoaded', () => {
let currentTagCard = null; let currentTagCard = null;
// Open modal when edit button clicked // Open modal when edit button clicked (using event delegation for infinite scroll)
document.querySelectorAll('.tag-edit-btn').forEach(btn => { document.addEventListener('click', async (e) => {
btn.addEventListener('click', async (e) => { const btn = e.target.closest('.tag-edit-btn');
e.preventDefault(); if (!btn) return;
e.stopPropagation();
const tagId = btn.dataset.tagId; e.preventDefault();
currentTagCard = btn.closest('.tag-card'); e.stopPropagation();
// Fetch tag details const tagId = btn.dataset.tagId;
try { currentTagCard = btn.closest('.tag-card');
const res = await fetch(`/api/tag/${tagId}`);
const data = await res.json();
if (data.ok) { // Fetch tag details
tagIdInput.value = data.tag.id; try {
nameInput.value = data.tag.display_name; const res = await fetch(`/api/tag/${tagId}`);
kindSelect.value = data.tag.kind || 'user'; const data = await res.json();
modal.classList.add('active');
} if (data.ok) {
} catch (err) { tagIdInput.value = data.tag.id;
console.error('Failed to load tag:', err); nameInput.value = data.tag.display_name;
kindSelect.value = data.tag.kind || 'user';
modal.classList.add('active');
} }
}); } catch (err) {
console.error('Failed to load tag:', err);
}
}); });
// Close modal // Close modal
+26
View File
@@ -1825,6 +1825,32 @@ select.form-input optgroup {
border-top: 1px solid rgba(255,255,255,0.05); border-top: 1px solid rgba(255,255,255,0.05);
} }
/* Infinite scroll loading indicator */
.loading-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--text-muted);
}
.loading-indicator .spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: var(--btn-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Scroll sentinel for IntersectionObserver */
.scroll-sentinel {
height: 1px;
width: 100%;
}
/* Play overlay with triangle icon */ /* Play overlay with triangle icon */
.play-overlay::before { .play-overlay::before {
content: ''; content: '';
+34
View File
@@ -0,0 +1,34 @@
<!-- /app/templates/_tag_cards.html -->
{% for tag in tag_data %}
<div class="tag-card" data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.tag }}" data-tag-kind="{{ tag.kind }}">
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="tag-card-link">
<div class="tag-preview">
{% for image in tag.images %}
<img class="tag-thumb"
src="{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}"
loading="lazy"
alt="">
{% endfor %}
{% if tag.images|length == 0 %}
<div class="tag-thumb tag-thumb-empty"></div>
{% endif %}
</div>
<div class="tag-card-info">
<span class="tag-icon">
{%- if tag.kind == 'artist' -%}🎨
{%- elif tag.kind == 'character' -%}👤
{%- elif tag.kind == 'series' -%}📺
{%- elif tag.kind == 'fandom' -%}🎭
{%- elif tag.kind == 'rating' -%}⚠️
{%- elif tag.kind == 'archive' -%}🗜️
{%- elif tag.kind == 'source' -%}🌐
{%- elif tag.kind == 'post' -%}📌
{%- else -%}#{%- endif -%}
</span>
<span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span>
<span class="tag-count">{{ tag.count }}</span>
</div>
</a>
<button class="tag-edit-btn" title="Edit tag" data-tag-id="{{ tag.id }}">✏️</button>
</div>
{% endfor %}
+106 -34
View File
@@ -70,41 +70,27 @@
</div> </div>
{% if tag_data %} {% if tag_data %}
<div class="tag-grid"> <div class="tag-grid" id="tagGrid">
{% for tag in tag_data %} {% include '_tag_cards.html' %}
<div class="tag-card" data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.tag }}" data-tag-kind="{{ tag.kind }}">
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="tag-card-link">
<div class="tag-preview">
{% for image in tag.images %}
<img class="tag-thumb"
src="{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}"
loading="lazy"
alt="">
{% endfor %}
{% if tag.images|length == 0 %}
<div class="tag-thumb tag-thumb-empty"></div>
{% endif %}
</div>
<div class="tag-card-info">
<span class="tag-icon">
{%- if tag.kind == 'artist' -%}🎨
{%- elif tag.kind == 'character' -%}👤
{%- elif tag.kind == 'series' -%}📺
{%- elif tag.kind == 'fandom' -%}🎭
{%- elif tag.kind == 'rating' -%}⚠️
{%- elif tag.kind == 'archive' -%}🗜️
{%- elif tag.kind == 'source' -%}🌐
{%- elif tag.kind == 'post' -%}📌
{%- else -%}#{%- endif -%}
</span>
<span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span>
<span class="tag-count">{{ tag.count }}</span>
</div>
</a>
<button class="tag-edit-btn" title="Edit tag" data-tag-id="{{ tag.id }}">✏️</button>
</div>
{% endfor %}
</div> </div>
<!-- Infinite scroll sentinel and loading indicator -->
<div id="tagScrollSentinel" class="scroll-sentinel" {% if not has_more %}style="display: none;"{% endif %}></div>
<div id="tagLoadingIndicator" class="loading-indicator" style="display: none;">
<span class="spinner"></span> Loading more tags...
</div>
<!-- Infinite scroll state -->
<script>
window.tagListState = {
kind: {{ (active_kind | tojson) if active_kind else 'null' }},
offset: {{ tag_data | length }},
pageSize: {{ page_size }},
hasMore: {{ 'true' if has_more else 'false' }},
loading: false,
total: {{ total_count }}
};
</script>
{% else %} {% else %}
<p class="empty-state">No tags found in this category.</p> <p class="empty-state">No tags found in this category.</p>
{% endif %} {% endif %}
@@ -146,4 +132,90 @@
</div> </div>
<script src="{{ url_for('static', filename='js/tag-editor.js') }}"></script> <script src="{{ url_for('static', filename='js/tag-editor.js') }}"></script>
<script>
// Tag list infinite scroll
(function() {
const state = window.tagListState;
if (!state) return;
const grid = document.getElementById('tagGrid');
const sentinel = document.getElementById('tagScrollSentinel');
const loading = document.getElementById('tagLoadingIndicator');
if (!grid || !sentinel) return;
async function loadMoreTags() {
if (state.loading || !state.hasMore) return;
state.loading = true;
loading.style.display = 'flex';
try {
const params = new URLSearchParams({
offset: state.offset,
limit: state.pageSize
});
if (state.kind) {
params.set('kind', state.kind);
}
const response = await fetch(`/api/tags/list?${params}`);
const data = await response.json();
if (data.ok && data.html) {
// Append new cards to grid
grid.insertAdjacentHTML('beforeend', data.html);
// Update state
state.offset = data.offset;
state.hasMore = data.has_more;
// Re-attach edit button listeners for new cards
attachEditListeners();
// Hide sentinel if no more data
if (!state.hasMore) {
sentinel.style.display = 'none';
}
}
} catch (error) {
console.error('Failed to load more tags:', error);
} finally {
state.loading = false;
loading.style.display = 'none';
}
}
// Re-attach edit button event listeners after loading new cards
function attachEditListeners() {
// The tag-editor.js handles this via event delegation on document,
// so new buttons should work automatically
}
// Set up IntersectionObserver for infinite scroll
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && state.hasMore && !state.loading) {
loadMoreTags();
}
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
} else {
// Fallback for older browsers: use scroll event
let scrollTimeout;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const scrollY = window.scrollY + window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
if (scrollY >= docHeight - 500 && state.hasMore && !state.loading) {
loadMoreTags();
}
}, 100);
});
}
})();
</script>
{% endblock %} {% endblock %}