import filters for pixiv sidecars, tag filtering, and favicon generation
@@ -1,28 +1,28 @@
|
||||
# Ignore test data
|
||||
images/
|
||||
import/
|
||||
|
||||
# Ignore Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Ignore environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Ignore virtual environments
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Ignore Git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore DB files
|
||||
imagerepo/
|
||||
|
||||
# Ignore Docker related files
|
||||
.dockerignore
|
||||
# Ignore test data
|
||||
images/
|
||||
import/
|
||||
|
||||
# Ignore Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Ignore environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Ignore virtual environments
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Ignore Git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore DB files
|
||||
imagerepo/
|
||||
|
||||
# Ignore Docker related files
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
@@ -1,13 +1,16 @@
|
||||
# Ignore imported and generated image folders
|
||||
images/
|
||||
import/
|
||||
imagerepo/
|
||||
|
||||
# Optional: Ignore SQLite DB, Python cache, virtual env, etc.
|
||||
*.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
env/
|
||||
venv/
|
||||
# Ignore imported and generated image folders
|
||||
images/
|
||||
import/
|
||||
imagerepo/
|
||||
|
||||
# Optional: Ignore SQLite DB, Python cache, virtual env, etc.
|
||||
*.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Claude files
|
||||
.claude/
|
||||
@@ -423,7 +423,7 @@ def gallery_jump():
|
||||
)
|
||||
|
||||
|
||||
def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
|
||||
def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None):
|
||||
"""
|
||||
Helper function to get tags with counts and preview images.
|
||||
Returns (tag_data list, total_count, has_more).
|
||||
@@ -436,6 +436,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
|
||||
count_q = db.session.query(func.count(Tag.id))
|
||||
if kind:
|
||||
count_q = count_q.filter(Tag.kind == kind)
|
||||
if search:
|
||||
count_q = count_q.filter(Tag.name.ilike(f'%{search}%'))
|
||||
total_count = count_q.scalar()
|
||||
|
||||
# Query 1: Get tags with image counts (paginated)
|
||||
@@ -446,6 +448,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
|
||||
|
||||
if kind:
|
||||
q = q.filter(Tag.kind == kind)
|
||||
if search:
|
||||
q = q.filter(Tag.name.ilike(f'%{search}%'))
|
||||
|
||||
tags_with_counts = q.order_by(Tag.name.asc()).offset(offset).limit(limit).all()
|
||||
|
||||
@@ -505,22 +509,25 @@ 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.
|
||||
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
|
||||
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
|
||||
kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag, search=search
|
||||
)
|
||||
|
||||
return render_template('tags_list.html',
|
||||
tag_data=tag_data,
|
||||
images_per_tag=images_per_tag,
|
||||
active_kind=kind,
|
||||
search_query=search or '',
|
||||
total_count=total_count,
|
||||
has_more=has_more,
|
||||
page_size=limit)
|
||||
@@ -533,12 +540,13 @@ def api_tags_list():
|
||||
Returns JSON with tag cards HTML and pagination info.
|
||||
"""
|
||||
kind = request.args.get('kind')
|
||||
search = request.args.get('search', '').strip() or None
|
||||
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
|
||||
kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag, search=search
|
||||
)
|
||||
|
||||
# Render tag cards to HTML
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 198 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 302 B |
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 220 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Outer frame -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="8" fill="#6366f1"/>
|
||||
<!-- Inner image area -->
|
||||
<rect x="10" y="10" width="44" height="44" rx="4" fill="#3c3c46"/>
|
||||
<!-- Sun -->
|
||||
<circle cx="44" cy="20" r="6" fill="#fbbf24"/>
|
||||
<!-- Back mountain -->
|
||||
<polygon points="10,48 32,24 50,48" fill="#5a5f6b"/>
|
||||
<!-- Front mountain -->
|
||||
<polygon points="24,48 44,30 54,48" fill="#50555f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -1,26 +1,26 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const url = new URL(window.location.href);
|
||||
const page = url.searchParams.get("page") || "1";
|
||||
const perPage = url.searchParams.get("per_page");
|
||||
|
||||
if (page !== "1" || perPage) return;
|
||||
|
||||
const grid = document.querySelector(".gallery-grid");
|
||||
if (!grid) return;
|
||||
|
||||
const sample = grid.querySelector(".gallery-item");
|
||||
if (!sample) return;
|
||||
|
||||
const gridStyle = window.getComputedStyle(grid);
|
||||
const gridGap = parseFloat(gridStyle.getPropertyValue("gap")) || 0;
|
||||
const sampleWidth = sample.getBoundingClientRect().width + gridGap;
|
||||
const containerWidth = grid.getBoundingClientRect().width;
|
||||
|
||||
const columns = Math.floor(containerWidth / sampleWidth);
|
||||
const calculatedPerPage = columns * 5;
|
||||
|
||||
url.searchParams.set("per_page", calculatedPerPage);
|
||||
url.searchParams.set("page", "1");
|
||||
|
||||
window.location.replace(url.toString());
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const url = new URL(window.location.href);
|
||||
const page = url.searchParams.get("page") || "1";
|
||||
const perPage = url.searchParams.get("per_page");
|
||||
|
||||
if (page !== "1" || perPage) return;
|
||||
|
||||
const grid = document.querySelector(".gallery-grid");
|
||||
if (!grid) return;
|
||||
|
||||
const sample = grid.querySelector(".gallery-item");
|
||||
if (!sample) return;
|
||||
|
||||
const gridStyle = window.getComputedStyle(grid);
|
||||
const gridGap = parseFloat(gridStyle.getPropertyValue("gap")) || 0;
|
||||
const sampleWidth = sample.getBoundingClientRect().width + gridGap;
|
||||
const containerWidth = grid.getBoundingClientRect().width;
|
||||
|
||||
const columns = Math.floor(containerWidth / sampleWidth);
|
||||
const calculatedPerPage = columns * 5;
|
||||
|
||||
url.searchParams.set("per_page", calculatedPerPage);
|
||||
url.searchParams.set("page", "1");
|
||||
|
||||
window.location.replace(url.toString());
|
||||
});
|
||||
|
||||
@@ -143,6 +143,58 @@ header {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Nav dropdown */
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-dropdown-trigger {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-dropdown-trigger::after {
|
||||
content: " \25BC";
|
||||
font-size: 0.65em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 150px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 1001;
|
||||
padding: 0.5rem 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu a {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu a:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .nav-dropdown-menu,
|
||||
.nav-dropdown:focus-within .nav-dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*------------------------------------------------------------------------------
|
||||
Flash messages
|
||||
------------------------------------------------------------------------------*/
|
||||
@@ -1003,6 +1055,42 @@ header {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Tag search bar */
|
||||
.tag-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.tag-search-input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.tag-search-input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tag-search-count {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Filter pills (top of tags_list) */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -31,6 +31,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
|
||||
Gallery-DL creates JSON files with various naming patterns:
|
||||
- Patreon: {filename}.json in same directory
|
||||
- HentaiFoundry: {artist}-{index}-{title}.json (may differ from image filename)
|
||||
- Pixiv: {id}_p{num}.json for image named {id}_{title}_{num}.png
|
||||
- UUID-based: {uuid}.json where UUID is extracted from filename
|
||||
|
||||
Returns the path to the sidecar JSON if found, None otherwise.
|
||||
@@ -50,7 +51,19 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
|
||||
if direct_json.exists():
|
||||
return str(direct_json)
|
||||
|
||||
# Pattern 3: Extract UUID from filename and look for {uuid}.json
|
||||
# Pattern 3: Pixiv naming pattern
|
||||
# Image: {id}_{title}_{num}.png (e.g., "115358744_Koseki Bijou (Bunny)_00.png")
|
||||
# JSON: {id}_p{num}.json (e.g., "115358744_p0.json")
|
||||
# Extract the leading numeric ID and trailing index
|
||||
pixiv_match = re.match(r'^(\d{6,12})_.*_(\d{2})$', stem)
|
||||
if pixiv_match:
|
||||
pixiv_id = pixiv_match.group(1)
|
||||
pixiv_num = int(pixiv_match.group(2)) # Convert "00" to 0
|
||||
pixiv_json = parent_dir / f"{pixiv_id}_p{pixiv_num}.json"
|
||||
if pixiv_json.exists():
|
||||
return str(pixiv_json)
|
||||
|
||||
# Pattern 4: Extract UUID from filename and look for {uuid}.json
|
||||
# Common patterns: "01_UUID", "UUID", "{num}_{UUID}"
|
||||
uuid_pattern = re.compile(r'([A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12})', re.IGNORECASE)
|
||||
match = uuid_pattern.search(stem)
|
||||
@@ -60,7 +73,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
|
||||
if uuid_json.exists():
|
||||
return str(uuid_json)
|
||||
|
||||
# Pattern 4: Just the stem without prefix numbers
|
||||
# Pattern 5: Just the stem without prefix numbers
|
||||
# e.g., "01_filename" -> look for "filename.json"
|
||||
stem_no_prefix = re.sub(r'^\d+_', '', stem)
|
||||
if stem_no_prefix != stem:
|
||||
@@ -68,7 +81,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
|
||||
if alt_json.exists():
|
||||
return str(alt_json)
|
||||
|
||||
# Pattern 5: Extract numeric ID from filename and find JSON with same ID
|
||||
# Pattern 6: Extract numeric ID from filename and find JSON with same ID
|
||||
# Handles HentaiFoundry where image is "hentaifoundry_1000220_title.jpg"
|
||||
# but JSON is "Artist-1000220-title.json"
|
||||
# Look for a 5-7 digit number that could be a post ID
|
||||
@@ -165,7 +178,7 @@ def load_sidecar_metadata(json_path: str) -> Optional[dict]:
|
||||
|
||||
def extract_platform(metadata: dict) -> Optional[str]:
|
||||
"""Extract the platform name from metadata."""
|
||||
# Check common fields
|
||||
# Check common fields (covers Pixiv which uses "category": "pixiv")
|
||||
if "category" in metadata:
|
||||
return metadata["category"].lower()
|
||||
|
||||
@@ -179,6 +192,8 @@ def extract_platform(metadata: dict) -> Optional[str]:
|
||||
return "subscribestar"
|
||||
if "discord.com" in url:
|
||||
return "discord"
|
||||
if "pixiv.net" in url or "pximg.net" in url:
|
||||
return "pixiv"
|
||||
|
||||
return None
|
||||
|
||||
@@ -195,8 +210,13 @@ def extract_artist(metadata: dict) -> Optional[str]:
|
||||
if "artist" in metadata:
|
||||
return metadata["artist"]
|
||||
|
||||
# For hentaifoundry
|
||||
if "user" in metadata:
|
||||
# For Pixiv - user is an object with name and account fields
|
||||
if "user" in metadata and isinstance(metadata["user"], dict):
|
||||
# Prefer account (username) over display name for consistency
|
||||
return metadata["user"].get("account") or metadata["user"].get("name")
|
||||
|
||||
# For hentaifoundry - user is a string
|
||||
if "user" in metadata and isinstance(metadata["user"], str):
|
||||
return metadata["user"]
|
||||
|
||||
return None
|
||||
@@ -238,6 +258,7 @@ def extract_description(metadata: dict) -> Optional[str]:
|
||||
- Patreon: 'content' (may be HTML)
|
||||
- SubscribeStar: 'content' (may be HTML)
|
||||
- HentaiFoundry: 'description' (plain text)
|
||||
- Pixiv: 'caption' (plain text or HTML)
|
||||
"""
|
||||
# Try description first (HentaiFoundry uses this)
|
||||
if "description" in metadata:
|
||||
@@ -245,6 +266,12 @@ def extract_description(metadata: dict) -> Optional[str]:
|
||||
if desc and isinstance(desc, str) and desc.strip():
|
||||
return desc.strip()
|
||||
|
||||
# Try caption (Pixiv uses this)
|
||||
if "caption" in metadata:
|
||||
caption = metadata["caption"]
|
||||
if caption and isinstance(caption, str) and caption.strip():
|
||||
return caption.strip()
|
||||
|
||||
# Try content (Patreon/SubscribeStar use this)
|
||||
if "content" in metadata:
|
||||
content = metadata["content"]
|
||||
@@ -268,14 +295,23 @@ def extract_source_url(metadata: dict) -> Optional[str]:
|
||||
- Patreon: 'url' (direct post link)
|
||||
- SubscribeStar: construct from author_name and post_id
|
||||
- HentaiFoundry: construct from user and index
|
||||
- Pixiv: construct from id
|
||||
"""
|
||||
platform = extract_platform(metadata)
|
||||
|
||||
# Direct URL field (Patreon)
|
||||
# Construct URL for Pixiv (do this first to avoid using the pximg.net image URL)
|
||||
if platform == "pixiv":
|
||||
post_id = metadata.get("id")
|
||||
if post_id:
|
||||
return f"https://www.pixiv.net/artworks/{post_id}"
|
||||
|
||||
# Direct URL field (Patreon) - but not for Pixiv where 'url' is the image URL
|
||||
if "url" in metadata:
|
||||
url = metadata["url"]
|
||||
if url and isinstance(url, str) and url.startswith("http"):
|
||||
return url
|
||||
# Skip pximg.net URLs (these are image URLs, not post URLs)
|
||||
if "pximg.net" not in url:
|
||||
return url
|
||||
|
||||
# Construct URL for SubscribeStar
|
||||
if platform == "subscribestar":
|
||||
@@ -298,6 +334,10 @@ def extract_attachment_count(metadata: dict) -> int:
|
||||
"""
|
||||
Extract the number of attachments/images in the post.
|
||||
"""
|
||||
# Pixiv: use page_count field
|
||||
if "page_count" in metadata:
|
||||
return int(metadata["page_count"])
|
||||
|
||||
# Patreon: check images array or post_metadata.image_order
|
||||
if "images" in metadata and isinstance(metadata["images"], list):
|
||||
return len(metadata["images"])
|
||||
@@ -319,7 +359,8 @@ def extract_date(metadata: dict) -> Optional[datetime]:
|
||||
Extract the publication/post date from metadata.
|
||||
Tries multiple common date fields and formats.
|
||||
"""
|
||||
date_fields = ["published_at", "date", "created_at", "timestamp"]
|
||||
# Pixiv uses create_date, others use published_at, date, etc.
|
||||
date_fields = ["published_at", "create_date", "date", "created_at", "timestamp"]
|
||||
|
||||
for field in date_fields:
|
||||
if field not in metadata:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate favicon files for ImageRepo.
|
||||
Creates a simple gallery/image icon in multiple formats and sizes.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import os
|
||||
|
||||
# Output directory
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'app', 'static')
|
||||
|
||||
# Color scheme (dark theme friendly)
|
||||
BG_COLOR = (45, 45, 55) # Dark background
|
||||
FRAME_COLOR = (99, 102, 241) # Indigo accent (matches --accent)
|
||||
IMAGE_BG = (60, 60, 70) # Slightly lighter for image area
|
||||
MOUNTAIN_COLOR = (80, 85, 95) # Mountain silhouette
|
||||
SUN_COLOR = (251, 191, 36) # Warm yellow sun
|
||||
|
||||
|
||||
def create_favicon_image(size):
|
||||
"""Create a gallery icon at the specified size."""
|
||||
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Padding and dimensions
|
||||
padding = max(1, size // 16)
|
||||
frame_width = max(1, size // 10)
|
||||
corner_radius = max(2, size // 8)
|
||||
|
||||
# Outer frame (rounded rectangle)
|
||||
frame_box = (padding, padding, size - padding, size - padding)
|
||||
draw.rounded_rectangle(frame_box, radius=corner_radius, fill=FRAME_COLOR)
|
||||
|
||||
# Inner image area
|
||||
inner_padding = padding + frame_width
|
||||
inner_box = (inner_padding, inner_padding, size - inner_padding, size - inner_padding)
|
||||
inner_radius = max(1, corner_radius - frame_width // 2)
|
||||
draw.rounded_rectangle(inner_box, radius=inner_radius, fill=IMAGE_BG)
|
||||
|
||||
# Simple landscape scene inside
|
||||
inner_width = size - 2 * inner_padding
|
||||
inner_height = size - 2 * inner_padding
|
||||
|
||||
if inner_width > 4 and inner_height > 4:
|
||||
# Sun (small circle in upper right)
|
||||
sun_size = max(2, inner_width // 5)
|
||||
sun_x = inner_padding + inner_width - sun_size - max(1, inner_width // 6)
|
||||
sun_y = inner_padding + max(1, inner_height // 6)
|
||||
draw.ellipse(
|
||||
(sun_x, sun_y, sun_x + sun_size, sun_y + sun_size),
|
||||
fill=SUN_COLOR
|
||||
)
|
||||
|
||||
# Mountains (triangular shapes)
|
||||
mountain_base_y = size - inner_padding - max(1, inner_height // 8)
|
||||
|
||||
# Left mountain (taller)
|
||||
peak1_x = inner_padding + inner_width // 3
|
||||
peak1_y = inner_padding + inner_height // 3
|
||||
draw.polygon([
|
||||
(inner_padding, mountain_base_y),
|
||||
(peak1_x, peak1_y),
|
||||
(inner_padding + int(inner_width * 0.6), mountain_base_y)
|
||||
], fill=MOUNTAIN_COLOR)
|
||||
|
||||
# Right mountain (shorter, overlapping)
|
||||
peak2_x = inner_padding + int(inner_width * 0.7)
|
||||
peak2_y = inner_padding + int(inner_height * 0.5)
|
||||
draw.polygon([
|
||||
(inner_padding + inner_width // 3, mountain_base_y),
|
||||
(peak2_x, peak2_y),
|
||||
(size - inner_padding, mountain_base_y)
|
||||
], fill=(90, 95, 105)) # Slightly lighter for depth
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def create_svg_favicon():
|
||||
"""Create an SVG favicon."""
|
||||
svg_content = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Outer frame -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="8" fill="#6366f1"/>
|
||||
<!-- Inner image area -->
|
||||
<rect x="10" y="10" width="44" height="44" rx="4" fill="#3c3c46"/>
|
||||
<!-- Sun -->
|
||||
<circle cx="44" cy="20" r="6" fill="#fbbf24"/>
|
||||
<!-- Back mountain -->
|
||||
<polygon points="10,48 32,24 50,48" fill="#5a5f6b"/>
|
||||
<!-- Front mountain -->
|
||||
<polygon points="24,48 44,30 54,48" fill="#50555f"/>
|
||||
</svg>'''
|
||||
|
||||
svg_path = os.path.join(STATIC_DIR, 'favicon.svg')
|
||||
with open(svg_path, 'w') as f:
|
||||
f.write(svg_content)
|
||||
print(f"Created: {svg_path}")
|
||||
return svg_path
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||
|
||||
# Generate SVG (scalable, modern browsers)
|
||||
create_svg_favicon()
|
||||
|
||||
# Generate PNG favicons at various sizes
|
||||
sizes = [16, 32, 48, 180, 192, 512]
|
||||
png_images = {}
|
||||
|
||||
for size in sizes:
|
||||
img = create_favicon_image(size)
|
||||
filename = f'favicon-{size}x{size}.png'
|
||||
filepath = os.path.join(STATIC_DIR, filename)
|
||||
img.save(filepath, 'PNG')
|
||||
png_images[size] = img
|
||||
print(f"Created: {filepath}")
|
||||
|
||||
# Create apple-touch-icon (180x180)
|
||||
apple_path = os.path.join(STATIC_DIR, 'apple-touch-icon.png')
|
||||
png_images[180].save(apple_path, 'PNG')
|
||||
print(f"Created: {apple_path}")
|
||||
|
||||
# Create ICO file with multiple sizes (16, 32, 48)
|
||||
ico_path = os.path.join(STATIC_DIR, 'favicon.ico')
|
||||
ico_sizes = [png_images[16], png_images[32], png_images[48]]
|
||||
ico_sizes[0].save(
|
||||
ico_path,
|
||||
format='ICO',
|
||||
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||
append_images=ico_sizes[1:]
|
||||
)
|
||||
print(f"Created: {ico_path}")
|
||||
|
||||
print("\nFavicon generation complete!")
|
||||
print("\nAdd these lines to your layout.html <head> section:")
|
||||
print('''
|
||||
<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') }}">
|
||||
''')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -4,7 +4,7 @@ A Flask-based image repository with Celery task processing for importing, organi
|
||||
|
||||
> **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications.
|
||||
>
|
||||
> **Last Updated**: 2026-02-01 (Settings dashboard: system stats, task filtering, error details)
|
||||
> **Last Updated**: 2026-02-04 (Pixiv sidecar support, Tags navbar dropdown, tag search)
|
||||
|
||||
---
|
||||
|
||||
@@ -422,12 +422,14 @@ Trigger via API: `POST /api/import/trigger` with `deep=true`
|
||||
|
||||
### Sidecar Metadata
|
||||
Gallery-DL JSON files provide:
|
||||
- Platform (patreon, hentaifoundry, etc.)
|
||||
- Platform (patreon, hentaifoundry, subscribestar, pixiv)
|
||||
- Artist name
|
||||
- Post ID and title
|
||||
- Publication date
|
||||
- Source URL
|
||||
|
||||
Pixiv-specific: Uses `{id}_p{num}.json` naming pattern, extracts from `user.account`, `create_date`, `caption`, constructs URL as `https://www.pixiv.net/artworks/{id}`
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||