import filters for pixiv sidecars, tag filtering, and favicon generation

This commit is contained in:
2026-02-04 19:08:50 -05:00
parent 17403c4e6b
commit c95896693f
24 changed files with 4615 additions and 4234 deletions
+74 -74
View File
@@ -1,74 +1,74 @@
<!-- /app/templates/_pagination.html -->
{% if images.pages > 1 %}
<div class="pagination-container">
{% set total_pages = images.pages %}
{% set current_page = images.page %}
{% set window_size = 7 %}
{% set endpoint = request.endpoint %}
{# Build a merged param dict: query args + path args #}
{% set params = request.args.to_dict(flat=True) %}
{% for k, v in request.view_args.items() %}
{% set _ = params.update({k: v}) %}
{% endfor %}
{# Helper to make a page URL while preserving all other params #}
{% macro page_url(p) -%}
{%- set pmap = params.copy() -%}
{%- set _ = pmap.update({'page': p}) -%}
{{ url_for(endpoint, **pmap) }}
{%- endmacro %}
{# First and Prev #}
{% if images.has_prev %}
{% if current_page > 1 %}
<a class="pagination-link" href="{{ page_url(1) }}">« First</a>
{% endif %}
<a class="pagination-link" href="{{ page_url(images.prev_num) }}">← Previous</a>
{% endif %}
{# Page window logic #}
{% if total_pages <= window_size %}
{% set start_page = 1 %}
{% set end_page = total_pages %}
{% elif current_page <= 4 %}
{% set start_page = 1 %}
{% set end_page = window_size %}
{% elif current_page > total_pages - 4 %}
{% set start_page = total_pages - window_size + 1 %}
{% set end_page = total_pages %}
{% else %}
{% set start_page = current_page - 3 %}
{% set end_page = current_page + 3 %}
{% endif %}
{# Left ellipsis #}
{% if start_page > 1 %}
<a class="pagination-link" href="{{ page_url(1) }}">1</a>
{% if start_page > 2 %}
<span class="pagination-ellipsis"></span>
{% endif %}
{% endif %}
{# Page numbers #}
{% for p in range(start_page, end_page + 1) %}
<a class="pagination-link {% if p == current_page %}active{% endif %}" href="{{ page_url(p) }}">{{ p }}</a>
{% endfor %}
{# Right ellipsis #}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<span class="pagination-ellipsis"></span>
{% endif %}
<a class="pagination-link" href="{{ page_url(total_pages) }}">{{ total_pages }}</a>
{% endif %}
{# Next and Last #}
{% if images.has_next %}
<a class="pagination-link" href="{{ page_url(images.next_num) }}">Next →</a>
{% if current_page < total_pages %}
<a class="pagination-link" href="{{ page_url(total_pages) }}">Last »</a>
{% endif %}
{% endif %}
</div>
{% endif %}
<!-- /app/templates/_pagination.html -->
{% if images.pages > 1 %}
<div class="pagination-container">
{% set total_pages = images.pages %}
{% set current_page = images.page %}
{% set window_size = 7 %}
{% set endpoint = request.endpoint %}
{# Build a merged param dict: query args + path args #}
{% set params = request.args.to_dict(flat=True) %}
{% for k, v in request.view_args.items() %}
{% set _ = params.update({k: v}) %}
{% endfor %}
{# Helper to make a page URL while preserving all other params #}
{% macro page_url(p) -%}
{%- set pmap = params.copy() -%}
{%- set _ = pmap.update({'page': p}) -%}
{{ url_for(endpoint, **pmap) }}
{%- endmacro %}
{# First and Prev #}
{% if images.has_prev %}
{% if current_page > 1 %}
<a class="pagination-link" href="{{ page_url(1) }}">« First</a>
{% endif %}
<a class="pagination-link" href="{{ page_url(images.prev_num) }}">← Previous</a>
{% endif %}
{# Page window logic #}
{% if total_pages <= window_size %}
{% set start_page = 1 %}
{% set end_page = total_pages %}
{% elif current_page <= 4 %}
{% set start_page = 1 %}
{% set end_page = window_size %}
{% elif current_page > total_pages - 4 %}
{% set start_page = total_pages - window_size + 1 %}
{% set end_page = total_pages %}
{% else %}
{% set start_page = current_page - 3 %}
{% set end_page = current_page + 3 %}
{% endif %}
{# Left ellipsis #}
{% if start_page > 1 %}
<a class="pagination-link" href="{{ page_url(1) }}">1</a>
{% if start_page > 2 %}
<span class="pagination-ellipsis"></span>
{% endif %}
{% endif %}
{# Page numbers #}
{% for p in range(start_page, end_page + 1) %}
<a class="pagination-link {% if p == current_page %}active{% endif %}" href="{{ page_url(p) }}">{{ p }}</a>
{% endfor %}
{# Right ellipsis #}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<span class="pagination-ellipsis"></span>
{% endif %}
<a class="pagination-link" href="{{ page_url(total_pages) }}">{{ total_pages }}</a>
{% endif %}
{# Next and Last #}
{% if images.has_next %}
<a class="pagination-link" href="{{ page_url(images.next_num) }}">Next →</a>
{% if current_page < total_pages %}
<a class="pagination-link" href="{{ page_url(total_pages) }}">Last »</a>
{% endif %}
{% endif %}
</div>
{% endif %}
+268 -268
View File
@@ -1,268 +1,268 @@
<!-- /app/templates/gallery.html -->
{% extends "layout.html" %}
{% block title %}Gallery{% endblock %}
{% block content %}
<!-- Floating Select Button (in navbar area) -->
<div id="floatingSelectBtn" class="floating-select-btn">
<button id="selectModeBtn" class="btn secondary-btn">Select</button>
</div>
<div class="gallery-infinite-container">
<!-- Timeline Scrollbar (left sidebar) -->
<aside id="timelineSidebar" class="timeline-sidebar">
<div id="timelineTrack" class="timeline-track">
<!-- Populated by JS with year-month markers -->
<div class="timeline-loading">Loading...</div>
</div>
</aside>
<!-- Main Gallery Content -->
<main class="gallery-main">
<div class="gallery-header">
<h1>Gallery</h1>
{% if active_tag %}
<div class="active-filter">
Filtered by: <span class="filter-tag">{{ active_tag }}</span>
<a href="{{ url_for('main.gallery') }}" class="clear-filter" title="Clear filter">×</a>
</div>
{% endif %}
</div>
{% if post_metadata %}
<!-- Post Info Header -->
<div class="post-info-header">
<div class="post-info-content">
{% if post_metadata.title %}
<h2 class="post-title">{{ post_metadata.title }}</h2>
{% endif %}
<div class="post-meta">
{% if post_metadata.artist %}
<span class="post-artist">{{ post_metadata.artist }}</span>
{% endif %}
{% if post_metadata.platform %}
<span class="post-platform post-platform-{{ post_metadata.platform }}">{{ post_metadata.platform }}</span>
{% endif %}
{% if post_metadata.published_at %}
<span class="post-date">{{ post_metadata.published_at.strftime('%B %d, %Y') }}</span>
{% endif %}
{% if post_metadata.attachment_count > 1 %}
<span class="post-count">{{ post_metadata.attachment_count }} images</span>
{% endif %}
</div>
{% if post_metadata.description %}
<div class="post-description">
{{ post_metadata.description | safe }}
</div>
{% endif %}
{% if post_metadata.source_url %}
<a href="{{ post_metadata.source_url }}" target="_blank" rel="noopener noreferrer" class="post-source-link">
View original post
</a>
{% endif %}
</div>
</div>
{% endif %}
{% if series_info %}
<!-- Series Header -->
<div class="series-header">
<div class="series-header-content">
<div class="series-header-left">
<h2 class="series-title">{{ series_info.name }}</h2>
<span id="seriesPageCount" class="series-page-count">{{ series_info.page_count }} pages</span>
</div>
<div class="series-header-actions">
{% if series_info.page_count > 0 %}
<a href="{{ url_for('main.read_series', tag_id=series_info.tag_id) }}" class="btn primary-btn">
Read Series
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Gallery Grid with Date Sections -->
<div id="galleryInfinite" class="gallery-infinite">
{% for year_month_key, year_month_label, images in grouped_images %}
<section class="date-section" data-year-month="{{ year_month_key }}">
<h2 class="date-divider" id="section-{{ year_month_key }}">
<span class="date-divider-text">{{ year_month_label }}</span>
<span class="date-divider-count">{{ images|length }} images</span>
</h2>
<div class="gallery-grid">
{% for image in images %}
{% include '_gallery_item.html' %}
{% endfor %}
</div>
</section>
{% else %}
<div class="gallery-empty">
<p>No images found{% if active_tag %} for tag "{{ active_tag }}"{% endif %}.</p>
</div>
{% endfor %}
</div>
<!-- Loading indicator -->
<div id="galleryLoading" class="gallery-loading" style="display:none;">
<span class="loading-spinner"></span>
<span>Loading more images...</span>
</div>
<!-- End of content marker -->
<div id="galleryEnd" class="gallery-end" style="display:none;">
No more images
</div>
</main>
</div>
<!-- Modal -->
{% include '_gallery_modal.html' %}
<!-- Bulk Editor Panel -->
<aside id="bulkEditorPanel" class="bulk-editor-panel">
<div class="bulk-editor-header">
<h3>Bulk Edit</h3>
<button id="closeBulkEditor" class="modal-close-btn">×</button>
</div>
<div class="bulk-editor-stats">
<span id="selectedCount">0</span> images selected
</div>
<div class="bulk-editor-section">
<h4>Add Tags</h4>
<form id="bulkAddTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
<input type="text" id="bulkTagInput" name="name" placeholder="Add tag..." autocomplete="off">
<div id="bulkTagAutocomplete" class="tag-autocomplete"></div>
</div>
<button type="submit">Add</button>
</form>
</div>
<div class="bulk-editor-section">
<h4>Remove Tags</h4>
<p class="form-help">Common tags across selected images:</p>
<div id="commonTagsList" class="tags"></div>
</div>
{% if series_info %}
<div class="bulk-editor-section bulk-editor-series">
<h4>📚 Add to Series</h4>
<p class="form-help">Add selected images as pages to "{{ series_info.name }}"</p>
<div class="series-editor-controls">
<div class="series-editor-input">
<label for="startPageInput">Starting page:</label>
<input type="number" id="startPageInput" value="{{ series_info.page_count + 1 }}" min="1" class="form-input">
</div>
<button id="bulkAddToSeriesBtn" class="btn primary-btn" disabled>
Add to Series
</button>
</div>
<div id="seriesEditorFeedback" class="series-editor-feedback"></div>
</div>
{% endif %}
<div class="bulk-editor-actions">
<button id="clearSelectionBtn" class="btn secondary-btn">Clear Selection</button>
</div>
</aside>
<!-- Pass initial state to JS -->
<script>
window.galleryState = {
cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }},
hasMore: {{ 'true' if has_more else 'false' }},
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }},
seriesInfo: {{ series_info | tojson | safe if series_info else 'null' }}
};
</script>
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
{% if series_info %}
<script>
// Series Editor functionality (integrated into bulk editor panel)
document.addEventListener('DOMContentLoaded', () => {
const startPageInput = document.getElementById('startPageInput');
const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn');
const feedbackEl = document.getElementById('seriesEditorFeedback');
const seriesTagId = {{ series_info.tag_id }};
// Update button state based on selection
function updateAddButtonState() {
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
bulkAddToSeriesBtn.disabled = selectedCount === 0;
bulkAddToSeriesBtn.textContent = selectedCount > 0
? `Add ${selectedCount} to Series`
: 'Add to Series';
}
// Listen for selection changes
document.addEventListener('selectionChanged', updateAddButtonState);
// Add selected images to series
bulkAddToSeriesBtn.addEventListener('click', async () => {
if (!window.selectedImages || window.selectedImages.size === 0) return;
// Use selectionOrder (click order) for page ordering
const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages);
const startPage = parseInt(startPageInput.value) || 1;
bulkAddToSeriesBtn.disabled = true;
bulkAddToSeriesBtn.textContent = 'Adding...';
feedbackEl.innerHTML = '';
try {
const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: imageIds, start_page: startPage })
});
const data = await r.json();
if (data.ok) {
let msg = `Added ${data.added.length} image(s) to series.`;
if (data.skipped.length > 0) {
msg += ` Skipped ${data.skipped.length}.`;
}
feedbackEl.innerHTML = `<div class="feedback-success">${msg}</div>`;
// Update the starting page input to the next available page
if (data.added.length > 0) {
const lastPage = data.added[data.added.length - 1].page_number;
startPageInput.value = lastPage + 1;
}
// Clear selection
if (window.clearSelection) {
window.clearSelection();
}
// Update page count in header
const pageCountEl = document.getElementById('seriesPageCount');
if (pageCountEl) {
const currentCount = parseInt(pageCountEl.textContent) || 0;
pageCountEl.textContent = `${currentCount + data.added.length} pages`;
}
// Show "Read Series" button if it was hidden (first pages added)
const readBtn = document.querySelector('.series-header-actions .primary-btn');
if (!readBtn && data.added.length > 0) {
location.reload(); // Simplest way to update the UI
}
} else {
feedbackEl.innerHTML = `<div class="feedback-error">${data.error}</div>`;
}
} catch (e) {
feedbackEl.innerHTML = `<div class="feedback-error">Error: ${e.message}</div>`;
} finally {
updateAddButtonState();
}
});
// Initial state
updateAddButtonState();
});
</script>
{% endif %}
{% endblock %}
<!-- /app/templates/gallery.html -->
{% extends "layout.html" %}
{% block title %}Gallery{% endblock %}
{% block content %}
<!-- Floating Select Button (in navbar area) -->
<div id="floatingSelectBtn" class="floating-select-btn">
<button id="selectModeBtn" class="btn secondary-btn">Select</button>
</div>
<div class="gallery-infinite-container">
<!-- Timeline Scrollbar (left sidebar) -->
<aside id="timelineSidebar" class="timeline-sidebar">
<div id="timelineTrack" class="timeline-track">
<!-- Populated by JS with year-month markers -->
<div class="timeline-loading">Loading...</div>
</div>
</aside>
<!-- Main Gallery Content -->
<main class="gallery-main">
<div class="gallery-header">
<h1>Gallery</h1>
{% if active_tag %}
<div class="active-filter">
Filtered by: <span class="filter-tag">{{ active_tag }}</span>
<a href="{{ url_for('main.gallery') }}" class="clear-filter" title="Clear filter">×</a>
</div>
{% endif %}
</div>
{% if post_metadata %}
<!-- Post Info Header -->
<div class="post-info-header">
<div class="post-info-content">
{% if post_metadata.title %}
<h2 class="post-title">{{ post_metadata.title }}</h2>
{% endif %}
<div class="post-meta">
{% if post_metadata.artist %}
<span class="post-artist">{{ post_metadata.artist }}</span>
{% endif %}
{% if post_metadata.platform %}
<span class="post-platform post-platform-{{ post_metadata.platform }}">{{ post_metadata.platform }}</span>
{% endif %}
{% if post_metadata.published_at %}
<span class="post-date">{{ post_metadata.published_at.strftime('%B %d, %Y') }}</span>
{% endif %}
{% if post_metadata.attachment_count > 1 %}
<span class="post-count">{{ post_metadata.attachment_count }} images</span>
{% endif %}
</div>
{% if post_metadata.description %}
<div class="post-description">
{{ post_metadata.description | safe }}
</div>
{% endif %}
{% if post_metadata.source_url %}
<a href="{{ post_metadata.source_url }}" target="_blank" rel="noopener noreferrer" class="post-source-link">
View original post
</a>
{% endif %}
</div>
</div>
{% endif %}
{% if series_info %}
<!-- Series Header -->
<div class="series-header">
<div class="series-header-content">
<div class="series-header-left">
<h2 class="series-title">{{ series_info.name }}</h2>
<span id="seriesPageCount" class="series-page-count">{{ series_info.page_count }} pages</span>
</div>
<div class="series-header-actions">
{% if series_info.page_count > 0 %}
<a href="{{ url_for('main.read_series', tag_id=series_info.tag_id) }}" class="btn primary-btn">
Read Series
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Gallery Grid with Date Sections -->
<div id="galleryInfinite" class="gallery-infinite">
{% for year_month_key, year_month_label, images in grouped_images %}
<section class="date-section" data-year-month="{{ year_month_key }}">
<h2 class="date-divider" id="section-{{ year_month_key }}">
<span class="date-divider-text">{{ year_month_label }}</span>
<span class="date-divider-count">{{ images|length }} images</span>
</h2>
<div class="gallery-grid">
{% for image in images %}
{% include '_gallery_item.html' %}
{% endfor %}
</div>
</section>
{% else %}
<div class="gallery-empty">
<p>No images found{% if active_tag %} for tag "{{ active_tag }}"{% endif %}.</p>
</div>
{% endfor %}
</div>
<!-- Loading indicator -->
<div id="galleryLoading" class="gallery-loading" style="display:none;">
<span class="loading-spinner"></span>
<span>Loading more images...</span>
</div>
<!-- End of content marker -->
<div id="galleryEnd" class="gallery-end" style="display:none;">
No more images
</div>
</main>
</div>
<!-- Modal -->
{% include '_gallery_modal.html' %}
<!-- Bulk Editor Panel -->
<aside id="bulkEditorPanel" class="bulk-editor-panel">
<div class="bulk-editor-header">
<h3>Bulk Edit</h3>
<button id="closeBulkEditor" class="modal-close-btn">×</button>
</div>
<div class="bulk-editor-stats">
<span id="selectedCount">0</span> images selected
</div>
<div class="bulk-editor-section">
<h4>Add Tags</h4>
<form id="bulkAddTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
<input type="text" id="bulkTagInput" name="name" placeholder="Add tag..." autocomplete="off">
<div id="bulkTagAutocomplete" class="tag-autocomplete"></div>
</div>
<button type="submit">Add</button>
</form>
</div>
<div class="bulk-editor-section">
<h4>Remove Tags</h4>
<p class="form-help">Common tags across selected images:</p>
<div id="commonTagsList" class="tags"></div>
</div>
{% if series_info %}
<div class="bulk-editor-section bulk-editor-series">
<h4>📚 Add to Series</h4>
<p class="form-help">Add selected images as pages to "{{ series_info.name }}"</p>
<div class="series-editor-controls">
<div class="series-editor-input">
<label for="startPageInput">Starting page:</label>
<input type="number" id="startPageInput" value="{{ series_info.page_count + 1 }}" min="1" class="form-input">
</div>
<button id="bulkAddToSeriesBtn" class="btn primary-btn" disabled>
Add to Series
</button>
</div>
<div id="seriesEditorFeedback" class="series-editor-feedback"></div>
</div>
{% endif %}
<div class="bulk-editor-actions">
<button id="clearSelectionBtn" class="btn secondary-btn">Clear Selection</button>
</div>
</aside>
<!-- Pass initial state to JS -->
<script>
window.galleryState = {
cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }},
hasMore: {{ 'true' if has_more else 'false' }},
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }},
seriesInfo: {{ series_info | tojson | safe if series_info else 'null' }}
};
</script>
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
{% if series_info %}
<script>
// Series Editor functionality (integrated into bulk editor panel)
document.addEventListener('DOMContentLoaded', () => {
const startPageInput = document.getElementById('startPageInput');
const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn');
const feedbackEl = document.getElementById('seriesEditorFeedback');
const seriesTagId = {{ series_info.tag_id }};
// Update button state based on selection
function updateAddButtonState() {
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
bulkAddToSeriesBtn.disabled = selectedCount === 0;
bulkAddToSeriesBtn.textContent = selectedCount > 0
? `Add ${selectedCount} to Series`
: 'Add to Series';
}
// Listen for selection changes
document.addEventListener('selectionChanged', updateAddButtonState);
// Add selected images to series
bulkAddToSeriesBtn.addEventListener('click', async () => {
if (!window.selectedImages || window.selectedImages.size === 0) return;
// Use selectionOrder (click order) for page ordering
const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages);
const startPage = parseInt(startPageInput.value) || 1;
bulkAddToSeriesBtn.disabled = true;
bulkAddToSeriesBtn.textContent = 'Adding...';
feedbackEl.innerHTML = '';
try {
const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: imageIds, start_page: startPage })
});
const data = await r.json();
if (data.ok) {
let msg = `Added ${data.added.length} image(s) to series.`;
if (data.skipped.length > 0) {
msg += ` Skipped ${data.skipped.length}.`;
}
feedbackEl.innerHTML = `<div class="feedback-success">${msg}</div>`;
// Update the starting page input to the next available page
if (data.added.length > 0) {
const lastPage = data.added[data.added.length - 1].page_number;
startPageInput.value = lastPage + 1;
}
// Clear selection
if (window.clearSelection) {
window.clearSelection();
}
// Update page count in header
const pageCountEl = document.getElementById('seriesPageCount');
if (pageCountEl) {
const currentCount = parseInt(pageCountEl.textContent) || 0;
pageCountEl.textContent = `${currentCount + data.added.length} pages`;
}
// Show "Read Series" button if it was hidden (first pages added)
const readBtn = document.querySelector('.series-header-actions .primary-btn');
if (!readBtn && data.added.length > 0) {
location.reload(); // Simplest way to update the UI
}
} else {
feedbackEl.innerHTML = `<div class="feedback-error">${data.error}</div>`;
}
} catch (e) {
feedbackEl.innerHTML = `<div class="feedback-error">Error: ${e.message}</div>`;
} finally {
updateAddButtonState();
}
});
// Initial state
updateAddButtonState();
});
</script>
{% endif %}
{% endblock %}
+18 -2
View File
@@ -3,6 +3,9 @@
<head>
<meta charset="utf-8">
<title>ImageRepo - {% block title %}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
@@ -13,8 +16,21 @@
<nav class="navbar">
<a class="nav-button" href="{{ url_for('main.index') }}">Showcase</a>
<a class="nav-button" href="{{ url_for('main.gallery') }}">Gallery</a>
<a class="nav-button" href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a>
<a class="nav-button" href="{{ url_for('main.tag_list') }}">Tags</a>
<div class="nav-dropdown">
<button class="nav-button nav-dropdown-trigger">Tags</button>
<div class="nav-dropdown-menu">
<a href="{{ url_for('main.tag_list') }}">All Tags</a>
<a href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a>
<a href="{{ url_for('main.tag_list', kind='character') }}">Characters</a>
<a href="{{ url_for('main.tag_list', kind='series') }}">Series</a>
<a href="{{ url_for('main.tag_list', kind='fandom') }}">Fandoms</a>
<a href="{{ url_for('main.tag_list', kind='rating') }}">Ratings</a>
<a href="{{ url_for('main.tag_list', kind='archive') }}">Archives</a>
<a href="{{ url_for('main.tag_list', kind='source') }}">Sources</a>
<a href="{{ url_for('main.tag_list', kind='post') }}">Posts</a>
<a href="{{ url_for('main.tag_list', kind='user') }}">User Tags</a>
</div>
</div>
<a class="nav-button" href="{{ url_for('main.settings') }}">Settings</a>
</nav>
</header>
+1819 -1819
View File
File diff suppressed because it is too large Load Diff
+287 -221
View File
@@ -1,221 +1,287 @@
{% extends "layout.html" %}
{% block title %}
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'fandom' -%}Fandoms
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'source' -%}Sources
{%- elif active_kind == 'post' -%}Posts
{%- elif active_kind == 'user' -%}User Tags
{%- else -%}Tags{%- endif -%}
{% endblock %}
{% block content %}
<h1>
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'fandom' -%}Fandoms
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'source' -%}Sources
{%- elif active_kind == 'post' -%}Posts
{%- elif active_kind == 'user' -%}User Tags
{%- else -%}All Tags{%- endif -%}
</h1>
<!-- Tag kind filter -->
<div class="filter-bar" role="tablist" aria-label="Filter tags by kind">
<a class="filter-btn {{ 'is-active' if not active_kind }}"
href="{{ url_for('main.tag_list') }}"
aria-current="{{ 'page' if not active_kind else 'false' }}">🏷️ All</a>
<a class="filter-btn {{ 'is-active' if active_kind=='artist' }}"
href="{{ url_for('main.tag_list', kind='artist') }}"
aria-current="{{ 'page' if active_kind=='artist' else 'false' }}">🎨 Artists</a>
<a class="filter-btn {{ 'is-active' if active_kind=='character' }}"
href="{{ url_for('main.tag_list', kind='character') }}"
aria-current="{{ 'page' if active_kind=='character' else 'false' }}">👤 Characters</a>
<a class="filter-btn {{ 'is-active' if active_kind=='series' }}"
href="{{ url_for('main.tag_list', kind='series') }}"
aria-current="{{ 'page' if active_kind=='series' else 'false' }}">📺 Series</a>
<a class="filter-btn {{ 'is-active' if active_kind=='fandom' }}"
href="{{ url_for('main.tag_list', kind='fandom') }}"
aria-current="{{ 'page' if active_kind=='fandom' else 'false' }}">🎭 Fandoms</a>
<a class="filter-btn {{ 'is-active' if active_kind=='rating' }}"
href="{{ url_for('main.tag_list', kind='rating') }}"
aria-current="{{ 'page' if active_kind=='rating' else 'false' }}">⚠️ Ratings</a>
<a class="filter-btn {{ 'is-active' if active_kind=='archive' }}"
href="{{ url_for('main.tag_list', kind='archive') }}"
aria-current="{{ 'page' if active_kind=='archive' else 'false' }}">🗜️ Archives</a>
<a class="filter-btn {{ 'is-active' if active_kind=='source' }}"
href="{{ url_for('main.tag_list', kind='source') }}"
aria-current="{{ 'page' if active_kind=='source' else 'false' }}">🌐 Sources</a>
<a class="filter-btn {{ 'is-active' if active_kind=='post' }}"
href="{{ url_for('main.tag_list', kind='post') }}"
aria-current="{{ 'page' if active_kind=='post' else 'false' }}">📌 Posts</a>
<a class="filter-btn {{ 'is-active' if active_kind=='user' }}"
href="{{ url_for('main.tag_list', kind='user') }}"
aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a>
</div>
{% if tag_data %}
<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 %}
<!-- Tag Edit Modal -->
<div id="tagEditModal" class="modal">
<div class="modal-content tag-edit-modal-content">
<button id="tagEditClose" class="modal-close-btn" title="Close">×</button>
<h2>Edit Tag</h2>
<form id="tagEditForm" class="tag-edit-form">
<input type="hidden" id="editTagId" name="tag_id">
<div class="form-group">
<label class="form-label" for="editTagName">Tag Name</label>
<input type="text" id="editTagName" name="name" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label" for="editTagKind">Category</label>
<select id="editTagKind" name="kind" class="form-input">
<option value="user"># User Tag</option>
<option value="artist">🎨 Artist</option>
<option value="character">👤 Character</option>
<option value="series">📺 Series</option>
<option value="fandom">🎭 Fandom</option>
<option value="rating">⚠️ Rating</option>
<option value="archive">🗜️ Archive</option>
<option value="source">🌐 Source</option>
<option value="post">📌 Post</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn primary-btn">Save Changes</button>
<button type="button" id="tagDeleteBtn" class="btn danger-btn">Delete Tag</button>
</div>
</form>
</div>
</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 %}
{% extends "layout.html" %}
{% block title %}
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'fandom' -%}Fandoms
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'source' -%}Sources
{%- elif active_kind == 'post' -%}Posts
{%- elif active_kind == 'user' -%}User Tags
{%- else -%}Tags{%- endif -%}
{% endblock %}
{% block content %}
<h1>
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'fandom' -%}Fandoms
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'source' -%}Sources
{%- elif active_kind == 'post' -%}Posts
{%- elif active_kind == 'user' -%}User Tags
{%- else -%}All Tags{%- endif -%}
</h1>
<!-- Search bar -->
<div class="tag-search-bar">
<input type="text"
id="tagSearchInput"
class="tag-search-input"
placeholder="Search tags..."
value="{{ search_query }}"
autocomplete="off">
<span id="tagSearchCount" class="tag-search-count">{{ total_count }} tags</span>
</div>
<!-- Tag kind filter -->
<div class="filter-bar" role="tablist" aria-label="Filter tags by kind">
<a class="filter-btn {{ 'is-active' if not active_kind }}"
href="{{ url_for('main.tag_list') }}"
aria-current="{{ 'page' if not active_kind else 'false' }}">🏷️ All</a>
<a class="filter-btn {{ 'is-active' if active_kind=='artist' }}"
href="{{ url_for('main.tag_list', kind='artist') }}"
aria-current="{{ 'page' if active_kind=='artist' else 'false' }}">🎨 Artists</a>
<a class="filter-btn {{ 'is-active' if active_kind=='character' }}"
href="{{ url_for('main.tag_list', kind='character') }}"
aria-current="{{ 'page' if active_kind=='character' else 'false' }}">👤 Characters</a>
<a class="filter-btn {{ 'is-active' if active_kind=='series' }}"
href="{{ url_for('main.tag_list', kind='series') }}"
aria-current="{{ 'page' if active_kind=='series' else 'false' }}">📺 Series</a>
<a class="filter-btn {{ 'is-active' if active_kind=='fandom' }}"
href="{{ url_for('main.tag_list', kind='fandom') }}"
aria-current="{{ 'page' if active_kind=='fandom' else 'false' }}">🎭 Fandoms</a>
<a class="filter-btn {{ 'is-active' if active_kind=='rating' }}"
href="{{ url_for('main.tag_list', kind='rating') }}"
aria-current="{{ 'page' if active_kind=='rating' else 'false' }}">⚠️ Ratings</a>
<a class="filter-btn {{ 'is-active' if active_kind=='archive' }}"
href="{{ url_for('main.tag_list', kind='archive') }}"
aria-current="{{ 'page' if active_kind=='archive' else 'false' }}">🗜️ Archives</a>
<a class="filter-btn {{ 'is-active' if active_kind=='source' }}"
href="{{ url_for('main.tag_list', kind='source') }}"
aria-current="{{ 'page' if active_kind=='source' else 'false' }}">🌐 Sources</a>
<a class="filter-btn {{ 'is-active' if active_kind=='post' }}"
href="{{ url_for('main.tag_list', kind='post') }}"
aria-current="{{ 'page' if active_kind=='post' else 'false' }}">📌 Posts</a>
<a class="filter-btn {{ 'is-active' if active_kind=='user' }}"
href="{{ url_for('main.tag_list', kind='user') }}"
aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a>
</div>
{% if tag_data %}
<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' }},
search: {{ (search_query | tojson) if search_query 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 %}
<!-- Tag Edit Modal -->
<div id="tagEditModal" class="modal">
<div class="modal-content tag-edit-modal-content">
<button id="tagEditClose" class="modal-close-btn" title="Close">×</button>
<h2>Edit Tag</h2>
<form id="tagEditForm" class="tag-edit-form">
<input type="hidden" id="editTagId" name="tag_id">
<div class="form-group">
<label class="form-label" for="editTagName">Tag Name</label>
<input type="text" id="editTagName" name="name" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label" for="editTagKind">Category</label>
<select id="editTagKind" name="kind" class="form-input">
<option value="user"># User Tag</option>
<option value="artist">🎨 Artist</option>
<option value="character">👤 Character</option>
<option value="series">📺 Series</option>
<option value="fandom">🎭 Fandom</option>
<option value="rating">⚠️ Rating</option>
<option value="archive">🗜️ Archive</option>
<option value="source">🌐 Source</option>
<option value="post">📌 Post</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn primary-btn">Save Changes</button>
<button type="button" id="tagDeleteBtn" class="btn danger-btn">Delete Tag</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='js/tag-editor.js') }}"></script>
<script>
// Tag list infinite scroll and search
(function() {
const state = window.tagListState;
if (!state) return;
const grid = document.getElementById('tagGrid');
const sentinel = document.getElementById('tagScrollSentinel');
const loading = document.getElementById('tagLoadingIndicator');
const searchInput = document.getElementById('tagSearchInput');
const searchCount = document.getElementById('tagSearchCount');
if (!grid || !sentinel) return;
let searchTimeout = null;
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);
}
if (state.search) {
params.set('search', state.search);
}
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;
// 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';
}
}
async function performSearch(query) {
state.loading = true;
loading.style.display = 'flex';
state.search = query || null;
state.offset = 0;
try {
const params = new URLSearchParams({
offset: 0,
limit: state.pageSize
});
if (state.kind) {
params.set('kind', state.kind);
}
if (state.search) {
params.set('search', state.search);
}
const response = await fetch(`/api/tags/list?${params}`);
const data = await response.json();
if (data.ok) {
// Replace grid contents
grid.innerHTML = data.html || '';
// Update state
state.offset = data.offset;
state.hasMore = data.has_more;
state.total = data.total;
// Update count display
if (searchCount) {
searchCount.textContent = `${data.total} tag${data.total !== 1 ? 's' : ''}`;
}
// Show/hide sentinel
sentinel.style.display = state.hasMore ? '' : 'none';
}
} catch (error) {
console.error('Failed to search tags:', error);
} finally {
state.loading = false;
loading.style.display = 'none';
}
}
// Live search with debounce
if (searchInput) {
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(e.target.value.trim());
}, 300);
});
}
// 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 %}