ui change to improve tag list load
This commit is contained in:
+66
-22
@@ -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():
|
||||
|
||||
+21
-20
@@ -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
|
||||
|
||||
@@ -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: '';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- /app/templates/_tag_cards.html -->
|
||||
{% for tag in tag_data %}
|
||||
<div class="tag-card" data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.tag }}" data-tag-kind="{{ tag.kind }}">
|
||||
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="tag-card-link">
|
||||
<div class="tag-preview">
|
||||
{% for image in tag.images %}
|
||||
<img class="tag-thumb"
|
||||
src="{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}"
|
||||
loading="lazy"
|
||||
alt="">
|
||||
{% endfor %}
|
||||
{% if tag.images|length == 0 %}
|
||||
<div class="tag-thumb tag-thumb-empty"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tag-card-info">
|
||||
<span class="tag-icon">
|
||||
{%- 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 -%}
|
||||
</span>
|
||||
<span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span>
|
||||
<span class="tag-count">{{ tag.count }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<button class="tag-edit-btn" title="Edit tag" data-tag-id="{{ tag.id }}">✏️</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
+106
-34
@@ -70,41 +70,27 @@
|
||||
</div>
|
||||
|
||||
{% if tag_data %}
|
||||
<div class="tag-grid">
|
||||
{% for tag in tag_data %}
|
||||
<div class="tag-card" data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.tag }}" data-tag-kind="{{ tag.kind }}">
|
||||
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="tag-card-link">
|
||||
<div class="tag-preview">
|
||||
{% for image in tag.images %}
|
||||
<img class="tag-thumb"
|
||||
src="{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}"
|
||||
loading="lazy"
|
||||
alt="">
|
||||
{% endfor %}
|
||||
{% if tag.images|length == 0 %}
|
||||
<div class="tag-thumb tag-thumb-empty"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tag-card-info">
|
||||
<span class="tag-icon">
|
||||
{%- 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 -%}
|
||||
</span>
|
||||
<span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span>
|
||||
<span class="tag-count">{{ tag.count }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<button class="tag-edit-btn" title="Edit tag" data-tag-id="{{ tag.id }}">✏️</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="tag-grid" id="tagGrid">
|
||||
{% include '_tag_cards.html' %}
|
||||
</div>
|
||||
|
||||
<!-- Infinite scroll sentinel and loading indicator -->
|
||||
<div id="tagScrollSentinel" class="scroll-sentinel" {% if not has_more %}style="display: none;"{% endif %}></div>
|
||||
<div id="tagLoadingIndicator" class="loading-indicator" style="display: none;">
|
||||
<span class="spinner"></span> Loading more tags...
|
||||
</div>
|
||||
|
||||
<!-- Infinite scroll state -->
|
||||
<script>
|
||||
window.tagListState = {
|
||||
kind: {{ (active_kind | tojson) if active_kind else 'null' }},
|
||||
offset: {{ tag_data | length }},
|
||||
pageSize: {{ page_size }},
|
||||
hasMore: {{ 'true' if has_more else 'false' }},
|
||||
loading: false,
|
||||
total: {{ total_count }}
|
||||
};
|
||||
</script>
|
||||
{% else %}
|
||||
<p class="empty-state">No tags found in this category.</p>
|
||||
{% endif %}
|
||||
@@ -146,4 +132,90 @@
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/tag-editor.js') }}"></script>
|
||||
<script>
|
||||
// Tag list infinite scroll
|
||||
(function() {
|
||||
const state = window.tagListState;
|
||||
if (!state) return;
|
||||
|
||||
const grid = document.getElementById('tagGrid');
|
||||
const sentinel = document.getElementById('tagScrollSentinel');
|
||||
const loading = document.getElementById('tagLoadingIndicator');
|
||||
if (!grid || !sentinel) return;
|
||||
|
||||
async function loadMoreTags() {
|
||||
if (state.loading || !state.hasMore) return;
|
||||
|
||||
state.loading = true;
|
||||
loading.style.display = 'flex';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
offset: state.offset,
|
||||
limit: state.pageSize
|
||||
});
|
||||
if (state.kind) {
|
||||
params.set('kind', state.kind);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tags/list?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok && data.html) {
|
||||
// Append new cards to grid
|
||||
grid.insertAdjacentHTML('beforeend', data.html);
|
||||
|
||||
// Update state
|
||||
state.offset = data.offset;
|
||||
state.hasMore = data.has_more;
|
||||
|
||||
// Re-attach edit button listeners for new cards
|
||||
attachEditListeners();
|
||||
|
||||
// Hide sentinel if no more data
|
||||
if (!state.hasMore) {
|
||||
sentinel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load more tags:', error);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-attach edit button event listeners after loading new cards
|
||||
function attachEditListeners() {
|
||||
// The tag-editor.js handles this via event delegation on document,
|
||||
// so new buttons should work automatically
|
||||
}
|
||||
|
||||
// Set up IntersectionObserver for infinite scroll
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && state.hasMore && !state.loading) {
|
||||
loadMoreTags();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
} else {
|
||||
// Fallback for older browsers: use scroll event
|
||||
let scrollTimeout;
|
||||
window.addEventListener('scroll', () => {
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
const scrollY = window.scrollY + window.innerHeight;
|
||||
const docHeight = document.documentElement.scrollHeight;
|
||||
if (scrollY >= docHeight - 500 && state.hasMore && !state.loading) {
|
||||
loadMoreTags();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user