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.
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,
+34
View File
@@ -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;
}
+3
View File
@@ -26,6 +26,9 @@
{%- else -%}#{%- endif -%}
</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>
</div>
</a>
+14
View File
@@ -80,6 +80,20 @@
aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a>
</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 %}
<div class="tag-grid" id="tagGrid">
{% include '_tag_cards.html' %}