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:
+26
-9
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user