feat(tags): fandom-less character nudges

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 16:07:17 -04:00
parent 0cae2b840d
commit 61f53a1ac1
4 changed files with 77 additions and 9 deletions
+26 -9
View File
@@ -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. Helper function to get tags with counts and preview images.
Returns (tag_data list, total_count, has_more). 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) count_q = count_q.filter(Tag.kind == kind)
if search: if search:
count_q = count_q.filter(Tag.name.ilike(f'%{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() total_count = count_q.scalar()
# Query 1: Get tags with image counts (paginated) # 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) q = q.filter(Tag.kind == kind)
if search: if search:
q = q.filter(Tag.name.ilike(f'%{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() 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, "tag": t.name,
"images": [], "images": [],
"kind": t.kind, "kind": t.kind,
"fandom_id": t.fandom_id,
"count": count "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(): def tag_list():
""" """
Generic tag explorer with infinite scroll: Generic tag explorer with infinite scroll:
/tags -> all tags /tags -> all tags
/tags?kind=user -> only user tags /tags?kind=user -> only user tags
/tags?search=xyz -> tags matching search term /tags?search=xyz -> tags matching search term
etc. /tags?null_fandom=1 -> only fandom-less character tags
Shows a few preview images per tag. Shows a few preview images per tag.
Initially loads 35 tags, more loaded via API as user scrolls. Initially loads 35 tags, more loaded via API as user scrolls.
""" """
kind = request.args.get('kind') kind = request.args.get('kind')
search = request.args.get('search', '').strip() or None search = request.args.get('search', '').strip() or None
null_fandom_only = request.args.get('null_fandom') == '1'
limit = 35 limit = 35
images_per_tag = 3 images_per_tag = 3
tag_data, total_count, has_more = _get_tags_with_previews( 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', return render_template('tags_list.html',
@@ -547,7 +560,9 @@ def tag_list():
search_query=search or '', search_query=search or '',
total_count=total_count, total_count=total_count,
has_more=has_more, 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') @main.route('/api/tags/list')
@@ -558,16 +573,18 @@ def api_tags_list():
""" """
kind = request.args.get('kind') kind = request.args.get('kind')
search = request.args.get('search', '').strip() or None 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) offset = request.args.get('offset', 0, type=int)
limit = request.args.get('limit', 35, type=int) limit = request.args.get('limit', 35, type=int)
images_per_tag = 3 images_per_tag = 3
tag_data, total_count, has_more = _get_tags_with_previews( 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 # 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( return jsonify(
ok=True, ok=True,
+34
View File
@@ -2998,3 +2998,37 @@ body.select-mode .gallery-infinite-container {
bottom: 4rem; 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;
}
+3
View File
@@ -26,6 +26,9 @@
{%- else -%}#{%- endif -%} {%- else -%}#{%- endif -%}
</span> </span>
<span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span> <span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span>
{% if tag.kind == 'character' and tag.fandom_id is none and not show_null_fandom_filter %}
<span class="character-no-fandom-chip" title="This character has no fandom — click to assign">⚠ No fandom</span>
{% endif %}
<span class="tag-count">{{ tag.count }}</span> <span class="tag-count">{{ tag.count }}</span>
</div> </div>
</a> </a>
+14
View File
@@ -80,6 +80,20 @@
aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a> aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a>
</div> </div>
{% if null_fandom_character_count > 0 and not show_null_fandom_filter %}
<a href="{{ url_for('main.tag_list', kind='character', null_fandom=1) }}"
class="null-fandom-counter-link">
⚠ {{ null_fandom_character_count }} character{{ 's' if null_fandom_character_count != 1 }} need a fandom
</a>
{% endif %}
{% if show_null_fandom_filter %}
<div class="null-fandom-filter-active">
Showing characters without a fandom.
<a href="{{ url_for('main.tag_list', kind='character') }}">Clear filter</a>
</div>
{% endif %}
{% if tag_data %} {% if tag_data %}
<div class="tag-grid" id="tagGrid"> <div class="tag-grid" id="tagGrid">
{% include '_tag_cards.html' %} {% include '_tag_cards.html' %}