From 61f53a1ac1318fea9f093e628c82e3485bada360 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 16:07:17 -0400 Subject: [PATCH] feat(tags): fandom-less character nudges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a header counter ('⚠ N characters need a fandom') that filters to ?null_fandom=1, plus an inline chip on each qualifying character card. Backend accepts null_fandom_only in the list query. Co-Authored-By: Claude Opus 4.7 --- app/main.py | 35 ++++++++++++++++++++++++++--------- app/static/style.css | 34 ++++++++++++++++++++++++++++++++++ app/templates/_tag_cards.html | 3 +++ app/templates/tags_list.html | 14 ++++++++++++++ 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/app/main.py b/app/main.py index 336e4d9..a9087c9 100644 --- a/app/main.py +++ b/app/main.py @@ -440,7 +440,7 @@ def gallery_jump(): ) -def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None): +def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None, null_fandom_only=False): """ Helper function to get tags with counts and preview images. Returns (tag_data list, total_count, has_more). @@ -455,6 +455,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, sea count_q = count_q.filter(Tag.kind == kind) if search: count_q = count_q.filter(Tag.name.ilike(f'%{search}%')) + if null_fandom_only: + count_q = count_q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None)) total_count = count_q.scalar() # Query 1: Get tags with image counts (paginated) @@ -467,6 +469,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, sea q = q.filter(Tag.kind == kind) if search: q = q.filter(Tag.name.ilike(f'%{search}%')) + if null_fandom_only: + q = q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None)) tags_with_counts = q.order_by(Tag.name.asc()).offset(offset).limit(limit).all() @@ -481,6 +485,7 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, sea "tag": t.name, "images": [], "kind": t.kind, + "fandom_id": t.fandom_id, "count": count } @@ -524,20 +529,28 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, sea def tag_list(): """ Generic tag explorer with infinite scroll: - /tags -> all tags - /tags?kind=user -> only user tags - /tags?search=xyz -> tags matching search term - etc. + /tags -> all tags + /tags?kind=user -> only user tags + /tags?search=xyz -> tags matching search term + /tags?null_fandom=1 -> only fandom-less character tags Shows a few preview images per tag. Initially loads 35 tags, more loaded via API as user scrolls. """ kind = request.args.get('kind') search = request.args.get('search', '').strip() or None + null_fandom_only = request.args.get('null_fandom') == '1' 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, search=search + kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag, + search=search, null_fandom_only=null_fandom_only + ) + + null_fandom_character_count = ( + db.session.query(func.count(Tag.id)) + .filter(Tag.kind == 'character', Tag.fandom_id.is_(None)) + .scalar() or 0 ) return render_template('tags_list.html', @@ -547,7 +560,9 @@ def tag_list(): search_query=search or '', total_count=total_count, has_more=has_more, - page_size=limit) + page_size=limit, + null_fandom_character_count=null_fandom_character_count, + show_null_fandom_filter=null_fandom_only) @main.route('/api/tags/list') @@ -558,16 +573,18 @@ def api_tags_list(): """ kind = request.args.get('kind') search = request.args.get('search', '').strip() or None + null_fandom_only = request.args.get('null_fandom') == '1' 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, search=search + kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag, + search=search, null_fandom_only=null_fandom_only ) # Render tag cards to HTML - cards_html = render_template('_tag_cards.html', tag_data=tag_data) + cards_html = render_template('_tag_cards.html', tag_data=tag_data, show_null_fandom_filter=null_fandom_only) return jsonify( ok=True, diff --git a/app/static/style.css b/app/static/style.css index 05391fa..af100a2 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -2998,3 +2998,37 @@ body.select-mode .gallery-infinite-container { bottom: 4rem; } } + +.null-fandom-counter-link { + display: inline-block; + padding: 0.35rem 0.75rem; + margin: 0.5rem 0; + background: rgba(255, 180, 0, 0.12); + border: 1px solid rgba(255, 180, 0, 0.3); + border-radius: 0.25rem; + color: #ffb400; + text-decoration: none; + font-size: 0.9rem; +} +.null-fandom-counter-link:hover { + background: rgba(255, 180, 0, 0.2); +} + +.null-fandom-filter-active { + padding: 0.5rem 0.75rem; + margin: 0.5rem 0; + background: rgba(255, 180, 0, 0.08); + border-left: 3px solid #ffb400; + font-size: 0.9rem; +} + +.character-no-fandom-chip { + display: inline-block; + padding: 0.1rem 0.4rem; + margin-left: 0.5rem; + font-size: 0.75rem; + background: rgba(255, 180, 0, 0.15); + color: #ffb400; + border-radius: 0.2rem; + cursor: help; +} diff --git a/app/templates/_tag_cards.html b/app/templates/_tag_cards.html index 8116009..3e429be 100644 --- a/app/templates/_tag_cards.html +++ b/app/templates/_tag_cards.html @@ -26,6 +26,9 @@ {%- else -%}#{%- endif -%} {{ tag.name }} + {% if tag.kind == 'character' and tag.fandom_id is none and not show_null_fandom_filter %} + ⚠ No fandom + {% endif %} {{ tag.count }} diff --git a/app/templates/tags_list.html b/app/templates/tags_list.html index 5a58e5e..3a4a027 100644 --- a/app/templates/tags_list.html +++ b/app/templates/tags_list.html @@ -80,6 +80,20 @@ aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User +{% if null_fandom_character_count > 0 and not show_null_fandom_filter %} + + ⚠ {{ null_fandom_character_count }} character{{ 's' if null_fandom_character_count != 1 }} need a fandom + +{% endif %} + +{% if show_null_fandom_filter %} +
+ Showing characters without a fandom. + Clear filter +
+{% endif %} + {% if tag_data %}
{% include '_tag_cards.html' %}