From 5af1dcbd4f79104267ca5669f768432eca64dc0c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 24 Jan 2026 12:14:27 -0500 Subject: [PATCH] ui change to improve tag list load --- app/main.py | 88 +++++++++++++++------ app/static/js/tag-editor.js | 41 +++++----- app/static/style.css | 26 +++++++ app/templates/_tag_cards.html | 34 +++++++++ app/templates/tags_list.html | 140 +++++++++++++++++++++++++--------- 5 files changed, 253 insertions(+), 76 deletions(-) create mode 100644 app/templates/_tag_cards.html diff --git a/app/main.py b/app/main.py index 6f8366b..c2b348c 100644 --- a/app/main.py +++ b/app/main.py @@ -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(): diff --git a/app/static/js/tag-editor.js b/app/static/js/tag-editor.js index 7590442..91548e0 100644 --- a/app/static/js/tag-editor.js +++ b/app/static/js/tag-editor.js @@ -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 diff --git a/app/static/style.css b/app/static/style.css index 7aa30a3..09a654a 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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: ''; diff --git a/app/templates/_tag_cards.html b/app/templates/_tag_cards.html new file mode 100644 index 0000000..ca7ecda --- /dev/null +++ b/app/templates/_tag_cards.html @@ -0,0 +1,34 @@ + +{% for tag in tag_data %} +
+ +
+ {% for image in tag.images %} + + {% endfor %} + {% if tag.images|length == 0 %} +
+ {% endif %} +
+
+ + {%- 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 -%} + + {{ tag.name }} + {{ tag.count }} +
+
+ +
+{% endfor %} diff --git a/app/templates/tags_list.html b/app/templates/tags_list.html index 84725d5..257ce18 100644 --- a/app/templates/tags_list.html +++ b/app/templates/tags_list.html @@ -70,41 +70,27 @@ {% if tag_data %} -
- {% for tag in tag_data %} - - {% endfor %} +
+ {% include '_tag_cards.html' %}
+ + +
+ + + + {% else %}

No tags found in this category.

{% endif %} @@ -146,4 +132,90 @@
+ {% endblock %}