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 tag_list():
def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
"""
Generic tag explorer:
/tags -> all tags
/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.
Helper function to get tags with counts and preview images.
Returns (tag_data list, total_count, has_more).
Optimized to use 2 queries instead of N+1:
1. Get all tags with counts
2. Get preview images for all tags at once using window function
1. Get tags with counts (paginated)
2. Get preview images for those tags using window function
"""
images_per_tag = 3
kind = request.args.get('kind')
# Query for total count
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(
Tag,
func.count(image_tags.c.image_id).label('img_count')
@@ -452,7 +447,7 @@ def tag_list():
if 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
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
# This fetches the top N images per tag in a single query
if tag_ids:
# Subquery to rank images within each tag
row_num = func.row_number().over(
partition_by=image_tags.c.tag_id,
order_by=desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
@@ -488,7 +481,6 @@ def tag_list():
.subquery()
)
# Get only the top N images per tag
preview_rows = (
db.session.query(subq.c.tag_id, ImageRecord)
.join(ImageRecord, ImageRecord.id == subq.c.image_id)
@@ -496,18 +488,70 @@ def tag_list():
.all()
)
# Group images by tag_id
for tag_id, img in preview_rows:
if tag_id in tag_map:
tag_map[tag_id]["images"].append(img)
# Convert to list maintaining original order
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',
tag_data=tag_data,
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')
def artist_list():
+21 -20
View File
@@ -13,30 +13,31 @@ document.addEventListener('DOMContentLoaded', () => {
let currentTagCard = null;
// Open modal when edit button clicked
document.querySelectorAll('.tag-edit-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// Open modal when edit button clicked (using event delegation for infinite scroll)
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.tag-edit-btn');
if (!btn) return;
const tagId = btn.dataset.tagId;
currentTagCard = btn.closest('.tag-card');
e.preventDefault();
e.stopPropagation();
// Fetch tag details
try {
const res = await fetch(`/api/tag/${tagId}`);
const data = await res.json();
const tagId = btn.dataset.tagId;
currentTagCard = btn.closest('.tag-card');
if (data.ok) {
tagIdInput.value = data.tag.id;
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);
// Fetch tag details
try {
const res = await fetch(`/api/tag/${tagId}`);
const data = await res.json();
if (data.ok) {
tagIdInput.value = data.tag.id;
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
+26
View File
@@ -1825,6 +1825,32 @@ select.form-input optgroup {
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::before {
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>
{% if tag_data %}
<div class="tag-grid">
{% 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 %}
<div class="tag-grid" id="tagGrid">
{% include '_tag_cards.html' %}
</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 %}
<p class="empty-state">No tags found in this category.</p>
{% endif %}
@@ -146,4 +132,90 @@
</div>
<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 %}