implement post metadata import and views. implemented series paging and reader view.
This commit is contained in:
+356
-3
@@ -84,6 +84,8 @@ def gallery():
|
|||||||
Server renders initial batch, then JS takes over for infinite scroll.
|
Server renders initial batch, then JS takes over for infinite scroll.
|
||||||
Optional tag filter via ?tag=artist:NAME or any tag name.
|
Optional tag filter via ?tag=artist:NAME or any tag name.
|
||||||
"""
|
"""
|
||||||
|
from app.models import PostMetadata
|
||||||
|
|
||||||
initial_limit = 30
|
initial_limit = 30
|
||||||
tag_name = request.args.get('tag')
|
tag_name = request.args.get('tag')
|
||||||
|
|
||||||
@@ -91,8 +93,11 @@ def gallery():
|
|||||||
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
||||||
|
|
||||||
# Optional tag filter
|
# Optional tag filter
|
||||||
|
active_tag_obj = None
|
||||||
if tag_name:
|
if tag_name:
|
||||||
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
active_tag_obj = Tag.query.filter_by(name=tag_name).first()
|
||||||
|
if active_tag_obj:
|
||||||
|
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||||
|
|
||||||
# Fetch initial batch ordered by date descending
|
# Fetch initial batch ordered by date descending
|
||||||
images = q.order_by(
|
images = q.order_by(
|
||||||
@@ -115,12 +120,42 @@ def gallery():
|
|||||||
if last_dt:
|
if last_dt:
|
||||||
initial_cursor = last_dt.isoformat()
|
initial_cursor = last_dt.isoformat()
|
||||||
|
|
||||||
|
# Fetch post metadata if filtering by a post tag
|
||||||
|
post_metadata = None
|
||||||
|
if active_tag_obj and active_tag_obj.kind == 'post':
|
||||||
|
pm = PostMetadata.query.filter_by(tag_id=active_tag_obj.id).first()
|
||||||
|
if pm:
|
||||||
|
post_metadata = {
|
||||||
|
'title': pm.title,
|
||||||
|
'description': pm.description,
|
||||||
|
'source_url': pm.source_url,
|
||||||
|
'platform': pm.platform,
|
||||||
|
'artist': pm.artist,
|
||||||
|
'post_id': pm.post_id,
|
||||||
|
'attachment_count': pm.attachment_count,
|
||||||
|
'published_at': pm.published_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch series info if filtering by a series tag
|
||||||
|
from app.models import SeriesPage
|
||||||
|
series_info = None
|
||||||
|
if active_tag_obj and active_tag_obj.kind == 'series':
|
||||||
|
page_count = SeriesPage.query.filter_by(series_tag_id=active_tag_obj.id).count()
|
||||||
|
series_info = {
|
||||||
|
'tag_id': active_tag_obj.id,
|
||||||
|
'name': active_tag_obj.name.split(':', 1)[1] if ':' in active_tag_obj.name else active_tag_obj.name,
|
||||||
|
'page_count': page_count,
|
||||||
|
}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'gallery.html',
|
'gallery.html',
|
||||||
grouped_images=grouped_images,
|
grouped_images=grouped_images,
|
||||||
active_tag=tag_name,
|
active_tag=tag_name,
|
||||||
|
active_tag_obj=active_tag_obj,
|
||||||
initial_cursor=initial_cursor,
|
initial_cursor=initial_cursor,
|
||||||
has_more=has_more
|
has_more=has_more,
|
||||||
|
post_metadata=post_metadata,
|
||||||
|
series_info=series_info
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -605,6 +640,73 @@ def get_tag(tag_id):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@main.get("/api/post-metadata/<int:tag_id>")
|
||||||
|
def get_post_metadata(tag_id):
|
||||||
|
"""
|
||||||
|
Get post metadata for a post tag.
|
||||||
|
Returns title, description, source URL, etc. for display in the gallery.
|
||||||
|
"""
|
||||||
|
from app.models import PostMetadata
|
||||||
|
|
||||||
|
tag = Tag.query.get_or_404(tag_id)
|
||||||
|
|
||||||
|
# Only post tags have metadata
|
||||||
|
if tag.kind != 'post':
|
||||||
|
return jsonify(ok=False, error="Not a post tag"), 400
|
||||||
|
|
||||||
|
pm = PostMetadata.query.filter_by(tag_id=tag_id).first()
|
||||||
|
if not pm:
|
||||||
|
return jsonify(ok=True, metadata=None)
|
||||||
|
|
||||||
|
return jsonify(ok=True, metadata={
|
||||||
|
"id": pm.id,
|
||||||
|
"tag_id": pm.tag_id,
|
||||||
|
"tag_name": tag.name,
|
||||||
|
"platform": pm.platform,
|
||||||
|
"post_id": pm.post_id,
|
||||||
|
"artist": pm.artist,
|
||||||
|
"title": pm.title,
|
||||||
|
"description": pm.description,
|
||||||
|
"source_url": pm.source_url,
|
||||||
|
"attachment_count": pm.attachment_count,
|
||||||
|
"published_at": pm.published_at.isoformat() if pm.published_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@main.get("/api/post-metadata/by-tag-name/<path:tag_name>")
|
||||||
|
def get_post_metadata_by_name(tag_name):
|
||||||
|
"""
|
||||||
|
Get post metadata by tag name (e.g., post:patreon:artist:12345).
|
||||||
|
Useful when filtering gallery by tag name.
|
||||||
|
"""
|
||||||
|
from app.models import PostMetadata
|
||||||
|
|
||||||
|
tag = Tag.query.filter_by(name=tag_name).first()
|
||||||
|
if not tag:
|
||||||
|
return jsonify(ok=True, metadata=None)
|
||||||
|
|
||||||
|
if tag.kind != 'post':
|
||||||
|
return jsonify(ok=False, error="Not a post tag"), 400
|
||||||
|
|
||||||
|
pm = PostMetadata.query.filter_by(tag_id=tag.id).first()
|
||||||
|
if not pm:
|
||||||
|
return jsonify(ok=True, metadata=None)
|
||||||
|
|
||||||
|
return jsonify(ok=True, metadata={
|
||||||
|
"id": pm.id,
|
||||||
|
"tag_id": pm.tag_id,
|
||||||
|
"tag_name": tag.name,
|
||||||
|
"platform": pm.platform,
|
||||||
|
"post_id": pm.post_id,
|
||||||
|
"artist": pm.artist,
|
||||||
|
"title": pm.title,
|
||||||
|
"description": pm.description,
|
||||||
|
"source_url": pm.source_url,
|
||||||
|
"attachment_count": pm.attachment_count,
|
||||||
|
"published_at": pm.published_at.isoformat() if pm.published_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@main.post("/api/tag/<int:tag_id>/update")
|
@main.post("/api/tag/<int:tag_id>/update")
|
||||||
def update_tag(tag_id):
|
def update_tag(tag_id):
|
||||||
"""
|
"""
|
||||||
@@ -627,7 +729,7 @@ def update_tag(tag_id):
|
|||||||
return jsonify(ok=False, error="Name is required"), 400
|
return jsonify(ok=False, error="Name is required"), 400
|
||||||
|
|
||||||
# Build the full tag name based on kind
|
# Build the full tag name based on kind
|
||||||
prefixed_kinds = ("artist", "archive", "character", "series", "rating")
|
prefixed_kinds = ("artist", "archive", "character", "series", "fandom", "rating")
|
||||||
if new_kind in prefixed_kinds:
|
if new_kind in prefixed_kinds:
|
||||||
# Remove any existing prefix from name if present
|
# Remove any existing prefix from name if present
|
||||||
if ":" in new_name:
|
if ":" in new_name:
|
||||||
@@ -1512,3 +1614,254 @@ def bulk_remove_tag():
|
|||||||
'ok': True,
|
'ok': True,
|
||||||
'removed_count': removed_count
|
'removed_count': removed_count
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# Series / Reader API
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
@main.route('/read/<int:tag_id>')
|
||||||
|
def read_series(tag_id):
|
||||||
|
"""
|
||||||
|
Reader view for a series - displays images in page order.
|
||||||
|
Supports jump-to-page via ?page=N parameter.
|
||||||
|
"""
|
||||||
|
from app.models import SeriesPage
|
||||||
|
|
||||||
|
tag = Tag.query.get_or_404(tag_id)
|
||||||
|
|
||||||
|
if tag.kind != 'series':
|
||||||
|
return "Not a series tag", 400
|
||||||
|
|
||||||
|
# Get all pages in order
|
||||||
|
pages = SeriesPage.query.filter_by(series_tag_id=tag_id).order_by(
|
||||||
|
SeriesPage.page_number
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Get starting page from query param
|
||||||
|
start_page = request.args.get('page', 1, type=int)
|
||||||
|
|
||||||
|
# Get series name (strip prefix if present)
|
||||||
|
series_name = tag.name.split(':', 1)[1] if ':' in tag.name else tag.name
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'reader.html',
|
||||||
|
tag=tag,
|
||||||
|
series_name=series_name,
|
||||||
|
pages=pages,
|
||||||
|
start_page=start_page,
|
||||||
|
total_pages=len(pages)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main.get('/api/series/<int:tag_id>/pages')
|
||||||
|
def get_series_pages(tag_id):
|
||||||
|
"""Get all pages in a series with their images."""
|
||||||
|
from app.models import SeriesPage
|
||||||
|
|
||||||
|
tag = Tag.query.get_or_404(tag_id)
|
||||||
|
|
||||||
|
if tag.kind != 'series':
|
||||||
|
return jsonify(ok=False, error="Not a series tag"), 400
|
||||||
|
|
||||||
|
pages = SeriesPage.query.filter_by(series_tag_id=tag_id).order_by(
|
||||||
|
SeriesPage.page_number
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return jsonify(ok=True, pages=[
|
||||||
|
{
|
||||||
|
'id': p.id,
|
||||||
|
'page_number': p.page_number,
|
||||||
|
'image_id': p.image_id,
|
||||||
|
'image': {
|
||||||
|
'id': p.image.id,
|
||||||
|
'filename': p.image.filename,
|
||||||
|
'filepath': p.image.filepath,
|
||||||
|
'thumb_path': p.image.thumb_path,
|
||||||
|
'width': p.image.width,
|
||||||
|
'height': p.image.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in pages
|
||||||
|
], total_pages=len(pages))
|
||||||
|
|
||||||
|
|
||||||
|
@main.post('/api/series/<int:tag_id>/pages/add')
|
||||||
|
def add_series_page(tag_id):
|
||||||
|
"""
|
||||||
|
Add an image to a series with a specific page number.
|
||||||
|
Expects form data: image_id, page_number
|
||||||
|
"""
|
||||||
|
from app.models import SeriesPage
|
||||||
|
|
||||||
|
tag = Tag.query.get_or_404(tag_id)
|
||||||
|
|
||||||
|
if tag.kind != 'series':
|
||||||
|
return jsonify(ok=False, error="Not a series tag"), 400
|
||||||
|
|
||||||
|
image_id = request.form.get('image_id', type=int)
|
||||||
|
page_number = request.form.get('page_number', type=int)
|
||||||
|
|
||||||
|
if not image_id or page_number is None:
|
||||||
|
return jsonify(ok=False, error="image_id and page_number required"), 400
|
||||||
|
|
||||||
|
# Check if image exists
|
||||||
|
image = ImageRecord.query.get(image_id)
|
||||||
|
if not image:
|
||||||
|
return jsonify(ok=False, error="Image not found"), 404
|
||||||
|
|
||||||
|
# Check if image is already in a series
|
||||||
|
existing = SeriesPage.query.filter_by(image_id=image_id).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify(ok=False, error="Image is already in a series",
|
||||||
|
existing_series_id=existing.series_tag_id), 409
|
||||||
|
|
||||||
|
# Check if page number is already taken
|
||||||
|
page_exists = SeriesPage.query.filter_by(
|
||||||
|
series_tag_id=tag_id, page_number=page_number
|
||||||
|
).first()
|
||||||
|
if page_exists:
|
||||||
|
return jsonify(ok=False, error=f"Page {page_number} already exists"), 409
|
||||||
|
|
||||||
|
# Create the series page
|
||||||
|
sp = SeriesPage(
|
||||||
|
series_tag_id=tag_id,
|
||||||
|
image_id=image_id,
|
||||||
|
page_number=page_number
|
||||||
|
)
|
||||||
|
db.session.add(sp)
|
||||||
|
|
||||||
|
# Also ensure the image has the series tag
|
||||||
|
if tag not in image.tags:
|
||||||
|
image.tags.append(tag)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(ok=True, page={
|
||||||
|
'id': sp.id,
|
||||||
|
'page_number': sp.page_number,
|
||||||
|
'image_id': sp.image_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@main.post('/api/series/<int:tag_id>/pages/bulk-add')
|
||||||
|
def bulk_add_series_pages(tag_id):
|
||||||
|
"""
|
||||||
|
Add multiple images to a series with sequential page numbers.
|
||||||
|
Expects JSON: { image_ids: [id1, id2, ...], start_page: N }
|
||||||
|
Images are added in order starting from start_page.
|
||||||
|
"""
|
||||||
|
from app.models import SeriesPage
|
||||||
|
|
||||||
|
tag = Tag.query.get_or_404(tag_id)
|
||||||
|
|
||||||
|
if tag.kind != 'series':
|
||||||
|
return jsonify(ok=False, error="Not a series tag"), 400
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
image_ids = data.get('image_ids', [])
|
||||||
|
start_page = data.get('start_page', 1)
|
||||||
|
|
||||||
|
if not image_ids:
|
||||||
|
return jsonify(ok=False, error="No image IDs provided"), 400
|
||||||
|
|
||||||
|
added = []
|
||||||
|
skipped = []
|
||||||
|
current_page = start_page
|
||||||
|
|
||||||
|
for image_id in image_ids:
|
||||||
|
image = ImageRecord.query.get(image_id)
|
||||||
|
if not image:
|
||||||
|
skipped.append({'image_id': image_id, 'reason': 'not found'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if image is already in a series
|
||||||
|
existing = SeriesPage.query.filter_by(image_id=image_id).first()
|
||||||
|
if existing:
|
||||||
|
skipped.append({'image_id': image_id, 'reason': 'already in series'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find next available page number
|
||||||
|
while SeriesPage.query.filter_by(
|
||||||
|
series_tag_id=tag_id, page_number=current_page
|
||||||
|
).first():
|
||||||
|
current_page += 1
|
||||||
|
|
||||||
|
sp = SeriesPage(
|
||||||
|
series_tag_id=tag_id,
|
||||||
|
image_id=image_id,
|
||||||
|
page_number=current_page
|
||||||
|
)
|
||||||
|
db.session.add(sp)
|
||||||
|
|
||||||
|
# Ensure image has the series tag
|
||||||
|
if tag not in image.tags:
|
||||||
|
image.tags.append(tag)
|
||||||
|
|
||||||
|
added.append({'image_id': image_id, 'page_number': current_page})
|
||||||
|
current_page += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(ok=True, added=added, skipped=skipped)
|
||||||
|
|
||||||
|
|
||||||
|
@main.post('/api/series/page/<int:page_id>/update')
|
||||||
|
def update_series_page(page_id):
|
||||||
|
"""Update a series page's page number."""
|
||||||
|
from app.models import SeriesPage
|
||||||
|
|
||||||
|
sp = SeriesPage.query.get_or_404(page_id)
|
||||||
|
new_page_number = request.form.get('page_number', type=int)
|
||||||
|
|
||||||
|
if new_page_number is None:
|
||||||
|
return jsonify(ok=False, error="page_number required"), 400
|
||||||
|
|
||||||
|
# Check if the new page number is taken
|
||||||
|
existing = SeriesPage.query.filter(
|
||||||
|
SeriesPage.series_tag_id == sp.series_tag_id,
|
||||||
|
SeriesPage.page_number == new_page_number,
|
||||||
|
SeriesPage.id != page_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return jsonify(ok=False, error=f"Page {new_page_number} already exists"), 409
|
||||||
|
|
||||||
|
sp.page_number = new_page_number
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(ok=True, page={
|
||||||
|
'id': sp.id,
|
||||||
|
'page_number': sp.page_number,
|
||||||
|
'image_id': sp.image_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@main.post('/api/series/page/<int:page_id>/delete')
|
||||||
|
def delete_series_page(page_id):
|
||||||
|
"""Remove an image from a series (doesn't delete the image)."""
|
||||||
|
from app.models import SeriesPage
|
||||||
|
|
||||||
|
sp = SeriesPage.query.get_or_404(page_id)
|
||||||
|
db.session.delete(sp)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@main.get('/api/image/<int:image_id>/series-info')
|
||||||
|
def get_image_series_info(image_id):
|
||||||
|
"""Check if an image is part of a series and return its page info."""
|
||||||
|
from app.models import SeriesPage
|
||||||
|
|
||||||
|
sp = SeriesPage.query.filter_by(image_id=image_id).first()
|
||||||
|
|
||||||
|
if not sp:
|
||||||
|
return jsonify(ok=True, in_series=False)
|
||||||
|
|
||||||
|
return jsonify(ok=True, in_series=True, series_page={
|
||||||
|
'id': sp.id,
|
||||||
|
'page_number': sp.page_number,
|
||||||
|
'series_tag_id': sp.series_tag_id,
|
||||||
|
'series_name': sp.series_tag.name
|
||||||
|
})
|
||||||
|
|||||||
@@ -101,6 +101,69 @@ class ImportBatch(db.Model):
|
|||||||
completed_at = db.Column(db.DateTime, nullable=True)
|
completed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PostMetadata(db.Model):
|
||||||
|
"""
|
||||||
|
Stores metadata about posts from external platforms (Patreon, SubscribeStar, HentaiFoundry).
|
||||||
|
Links to a Tag with kind='post' (format: post:platform:artist:id).
|
||||||
|
"""
|
||||||
|
__tablename__ = "post_metadata"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Link to the post tag (post:platform:artist:id)
|
||||||
|
tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||||
|
tag = db.relationship("Tag", backref=db.backref("post_metadata", uselist=False))
|
||||||
|
|
||||||
|
# Platform identification
|
||||||
|
platform = db.Column(db.String(64), nullable=False) # patreon, subscribestar, hentaifoundry
|
||||||
|
post_id = db.Column(db.String(64), nullable=False) # Original post ID on platform
|
||||||
|
artist = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
title = db.Column(db.String(512), nullable=True)
|
||||||
|
description = db.Column(db.Text, nullable=True) # May contain HTML
|
||||||
|
source_url = db.Column(db.String(1024), nullable=True) # Link to original post
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
attachment_count = db.Column(db.Integer, default=0) # Number of images in post
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
published_at = db.Column(db.DateTime, nullable=True) # Original publish date
|
||||||
|
created_at = db.Column(db.DateTime, default=db.func.now())
|
||||||
|
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesPage(db.Model):
|
||||||
|
"""
|
||||||
|
Links images to series tags with page numbers for ordered reading.
|
||||||
|
An image can only belong to one series at a time.
|
||||||
|
"""
|
||||||
|
__tablename__ = "series_page"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Link to the series tag (must be kind='series')
|
||||||
|
series_tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
series_tag = db.relationship("Tag", backref=db.backref("series_pages", lazy="dynamic"))
|
||||||
|
|
||||||
|
# Link to the image (unique - image can only be in one series)
|
||||||
|
image_id = db.Column(db.Integer, db.ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||||
|
image = db.relationship("ImageRecord", backref=db.backref("series_page", uselist=False))
|
||||||
|
|
||||||
|
# Page number within the series
|
||||||
|
page_number = db.Column(db.Integer, nullable=False)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=db.func.now())
|
||||||
|
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Each page number must be unique within a series
|
||||||
|
db.UniqueConstraint('series_tag_id', 'page_number', name='uq_series_page_number'),
|
||||||
|
db.Index('ix_series_page_series_tag', 'series_tag_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImportTask(db.Model):
|
class ImportTask(db.Model):
|
||||||
"""
|
"""
|
||||||
Tracks the state of each file being processed through the import pipeline.
|
Tracks the state of each file being processed through the import pipeline.
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
|
|
||||||
updateCount();
|
updateCount();
|
||||||
loadCommonTags();
|
loadCommonTags();
|
||||||
|
dispatchSelectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
@@ -121,6 +122,7 @@
|
|||||||
});
|
});
|
||||||
updateCount();
|
updateCount();
|
||||||
updateCommonTagsDisplay();
|
updateCommonTagsDisplay();
|
||||||
|
dispatchSelectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCount() {
|
function updateCount() {
|
||||||
@@ -129,6 +131,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchSelectionChanged() {
|
||||||
|
document.dispatchEvent(new CustomEvent('selectionChanged', {
|
||||||
|
detail: { count: state.selectedIds.size }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable select mode programmatically, optionally without opening the bulk panel
|
||||||
|
function enableSelectMode(skipPanel = false) {
|
||||||
|
if (state.isSelectMode) return;
|
||||||
|
|
||||||
|
state.isSelectMode = true;
|
||||||
|
document.body.classList.add('select-mode');
|
||||||
|
dom.selectBtn.textContent = 'Cancel';
|
||||||
|
dom.selectBtn.classList.add('active');
|
||||||
|
|
||||||
|
if (!skipPanel) {
|
||||||
|
dom.panel.classList.add('active');
|
||||||
|
updateCommonTagsDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable select mode
|
||||||
|
function disableSelectMode() {
|
||||||
|
if (!state.isSelectMode) return;
|
||||||
|
|
||||||
|
state.isSelectMode = false;
|
||||||
|
document.body.classList.remove('select-mode');
|
||||||
|
dom.selectBtn.textContent = 'Select';
|
||||||
|
dom.selectBtn.classList.remove('active');
|
||||||
|
dom.panel.classList.remove('active');
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
function updateSelectionUI() {
|
function updateSelectionUI() {
|
||||||
// Re-apply selected class to any newly loaded images that are in selection
|
// Re-apply selected class to any newly loaded images that are in selection
|
||||||
document.querySelectorAll('.img-clickable').forEach(item => {
|
document.querySelectorAll('.img-clickable').forEach(item => {
|
||||||
@@ -416,6 +451,7 @@
|
|||||||
archive: '🗜️',
|
archive: '🗜️',
|
||||||
character: '👤',
|
character: '👤',
|
||||||
series: '📺',
|
series: '📺',
|
||||||
|
fandom: '🎭',
|
||||||
rating: '⚠️',
|
rating: '⚠️',
|
||||||
source: '🌐',
|
source: '🌐',
|
||||||
post: '📌'
|
post: '📌'
|
||||||
@@ -435,4 +471,10 @@
|
|||||||
} else {
|
} else {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose global API for other scripts (like series editor)
|
||||||
|
window.selectedImages = state.selectedIds;
|
||||||
|
window.clearSelection = clearSelection;
|
||||||
|
window.enableSelectMode = enableSelectMode;
|
||||||
|
window.disableSelectMode = disableSelectMode;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
|
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
|
||||||
const tagAutocomplete = document.getElementById('tagAutocomplete');
|
const tagAutocomplete = document.getElementById('tagAutocomplete');
|
||||||
|
|
||||||
|
// Series info elements
|
||||||
|
const seriesInfoEl = document.getElementById('modalSeriesInfo');
|
||||||
|
const seriesNameEl = document.getElementById('modalSeriesName');
|
||||||
|
const pageNumberInput = document.getElementById('modalPageNumber');
|
||||||
|
const updatePageBtn = document.getElementById('updatePageBtn');
|
||||||
|
const pageUpdateStatus = document.getElementById('pageUpdateStatus');
|
||||||
|
let currentSeriesPageId = null;
|
||||||
|
|
||||||
// Autocomplete state
|
// Autocomplete state
|
||||||
let autocompleteItems = [];
|
let autocompleteItems = [];
|
||||||
let autocompleteSelectedIndex = -1;
|
let autocompleteSelectedIndex = -1;
|
||||||
@@ -53,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
archive: '🗜️',
|
archive: '🗜️',
|
||||||
character: '👤',
|
character: '👤',
|
||||||
series: '📺',
|
series: '📺',
|
||||||
|
fandom: '🎭',
|
||||||
rating: '⚠️',
|
rating: '⚠️',
|
||||||
source: '🌐',
|
source: '🌐',
|
||||||
post: '📌'
|
post: '📌'
|
||||||
@@ -87,6 +96,100 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Series info helpers
|
||||||
|
// ---------------------------
|
||||||
|
function hideSeriesInfo() {
|
||||||
|
if (seriesInfoEl) seriesInfoEl.style.display = 'none';
|
||||||
|
currentSeriesPageId = null;
|
||||||
|
if (pageUpdateStatus) pageUpdateStatus.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSeriesInfo(seriesPage) {
|
||||||
|
if (!seriesInfoEl || !seriesPage) return;
|
||||||
|
currentSeriesPageId = seriesPage.id;
|
||||||
|
|
||||||
|
// Display series name (strip prefix)
|
||||||
|
const displayName = seriesPage.series_name.includes(':')
|
||||||
|
? seriesPage.series_name.split(':', 1)[1]
|
||||||
|
: seriesPage.series_name;
|
||||||
|
if (seriesNameEl) {
|
||||||
|
seriesNameEl.textContent = displayName;
|
||||||
|
seriesNameEl.href = `/gallery?tag=${encodeURIComponent(seriesPage.series_name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display page number
|
||||||
|
if (pageNumberInput) {
|
||||||
|
pageNumberInput.value = seriesPage.page_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageUpdateStatus) pageUpdateStatus.textContent = '';
|
||||||
|
seriesInfoEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSeriesInfo(imageId) {
|
||||||
|
hideSeriesInfo();
|
||||||
|
if (!imageId) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/image/${imageId}/series-info`);
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok && j.in_series) {
|
||||||
|
showSeriesInfo(j.series_page);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePageNumber() {
|
||||||
|
if (!currentSeriesPageId || !pageNumberInput) return;
|
||||||
|
const newPageNumber = parseInt(pageNumberInput.value);
|
||||||
|
if (isNaN(newPageNumber) || newPageNumber < 1) {
|
||||||
|
if (pageUpdateStatus) pageUpdateStatus.textContent = 'Invalid';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatePageBtn) updatePageBtn.disabled = true;
|
||||||
|
if (pageUpdateStatus) pageUpdateStatus.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('page_number', newPageNumber);
|
||||||
|
const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
if (pageUpdateStatus) {
|
||||||
|
pageUpdateStatus.textContent = '✓';
|
||||||
|
setTimeout(() => { pageUpdateStatus.textContent = ''; }, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pageUpdateStatus) pageUpdateStatus.textContent = j.error || 'Error';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (pageUpdateStatus) pageUpdateStatus.textContent = 'Error';
|
||||||
|
} finally {
|
||||||
|
if (updatePageBtn) updatePageBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up the save button
|
||||||
|
if (updatePageBtn) {
|
||||||
|
updatePageBtn.addEventListener('click', updatePageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow Enter key in page number input to save
|
||||||
|
if (pageNumberInput) {
|
||||||
|
pageNumberInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
updatePageNumber();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
async function addTag(imageId, name) {
|
async function addTag(imageId, name) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('name', name);
|
fd.append('name', name);
|
||||||
@@ -299,6 +402,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// NEW: load tags for this image into the modal editor
|
// NEW: load tags for this image into the modal editor
|
||||||
setEditorImageId(imageId);
|
setEditorImageId(imageId);
|
||||||
loadTags(imageId);
|
loadTags(imageId);
|
||||||
|
loadSeriesInfo(imageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(index) {
|
function openModal(index) {
|
||||||
@@ -324,6 +428,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// clear tag UI
|
// clear tag UI
|
||||||
setEditorImageId('');
|
setEditorImageId('');
|
||||||
renderTags([]);
|
renderTags([]);
|
||||||
|
hideSeriesInfo();
|
||||||
history.replaceState({}, '', window.location.pathname + window.location.search);
|
history.replaceState({}, '', window.location.pathname + window.location.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,7 @@
|
|||||||
archive: '🗜️',
|
archive: '🗜️',
|
||||||
character: '👤',
|
character: '👤',
|
||||||
series: '📺',
|
series: '📺',
|
||||||
|
fandom: '🎭',
|
||||||
rating: '⚠️',
|
rating: '⚠️',
|
||||||
source: '🌐',
|
source: '🌐',
|
||||||
post: '📌'
|
post: '📌'
|
||||||
|
|||||||
@@ -585,6 +585,63 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Series info in modal tag editor */
|
||||||
|
.modal-series-info {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.series-info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.series-info-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.series-info-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.series-info-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.series-info-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.series-page-input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: var(--btn-secondary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-small:hover {
|
||||||
|
background: var(--btn-secondary-hover);
|
||||||
|
}
|
||||||
|
.btn-small:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-update-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.tag-editor .tags {
|
.tag-editor .tags {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
@@ -1407,6 +1464,261 @@ select.form-input optgroup {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Post Info Header (shown when filtering by post tag) */
|
||||||
|
.post-info-header {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.post-info-content {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
.post-title {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.post-artist {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
.post-platform {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.post-platform-patreon {
|
||||||
|
background: rgba(255, 66, 77, 0.2);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
.post-platform-subscribestar {
|
||||||
|
background: rgba(255, 153, 0, 0.2);
|
||||||
|
color: #ffaa33;
|
||||||
|
}
|
||||||
|
.post-platform-hentaifoundry {
|
||||||
|
background: rgba(139, 92, 246, 0.2);
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
.post-date {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.post-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.post-count::before {
|
||||||
|
content: "•";
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.post-description {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.post-description:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Style any HTML content in descriptions */
|
||||||
|
.post-description a {
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
.post-description a:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
.post-description p {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
.post-description p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.post-description img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.post-source-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.post-source-link:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.25);
|
||||||
|
border-color: rgba(99, 102, 241, 0.5);
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
.post-source-link::after {
|
||||||
|
content: "↗";
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Series Header (shown when filtering by series tag) */
|
||||||
|
.series-header {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.series-header-content {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.series-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.series-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.series-page-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.series-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Series Editor Panel */
|
||||||
|
.series-editor-panel {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.series-editor-info {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.series-editor-info p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.series-editor-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.series-editor-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.series-editor-input label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.series-editor-input input {
|
||||||
|
width: 100px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.series-editor-feedback {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.series-editor-feedback .feedback-success {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.series-editor-feedback .feedback-error {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#seriesEditModeBtn.active {
|
||||||
|
background: var(--btn-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.series-header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.series-editor-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.series-editor-input {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.post-info-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.post-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
.post-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.post-description {
|
||||||
|
max-height: 150px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Gallery content area */
|
/* Gallery content area */
|
||||||
.gallery-infinite {
|
.gallery-infinite {
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
@@ -1703,6 +2015,21 @@ body.select-mode .gallery-infinite-container {
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Series section in bulk editor */
|
||||||
|
.bulk-editor-series {
|
||||||
|
background: rgba(100, 100, 255, 0.05);
|
||||||
|
border-top: 1px solid rgba(100, 100, 255, 0.15);
|
||||||
|
}
|
||||||
|
.bulk-editor-series h4 {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.bulk-editor-series .series-editor-controls {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.bulk-editor-series .series-editor-feedback {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Text muted helper */
|
/* Text muted helper */
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1727,3 +2054,355 @@ body.select-mode .gallery-infinite-container {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*------------------------------------------------------------------------------
|
||||||
|
Series Reader View
|
||||||
|
------------------------------------------------------------------------------*/
|
||||||
|
.reader-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
background: var(--bg);
|
||||||
|
margin: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-back-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-page-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-jump {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-jump label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-jump-select {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reader-jump-select option {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toggle-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toggle-nav:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Sidebar */
|
||||||
|
.reader-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 200px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-thumbs {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-thumb {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-thumb:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-thumb.active {
|
||||||
|
border-color: var(--btn-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-page-num {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Reader Content */
|
||||||
|
.reader-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-page {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-page img {
|
||||||
|
max-width: min(100%, 1200px);
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-page:hover .page-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating page indicator */
|
||||||
|
.floating-page-indicator {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick nav buttons */
|
||||||
|
.reader-quick-nav {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.5rem;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-quick-btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-quick-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.reader-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-jump label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-nav {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-page-indicator {
|
||||||
|
bottom: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-quick-nav {
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+49
-3
@@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import ImageRecord, Tag
|
from app.models import ImageRecord, Tag, PostMetadata
|
||||||
|
|
||||||
log = logging.getLogger('celery.tasks.sidecar')
|
log = logging.getLogger('celery.tasks.sidecar')
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ def apply_sidecar_metadata(self, image_id: int, sidecar_path: str):
|
|||||||
return {'status': 'no_metadata', 'reason': 'No metadata extracted'}
|
return {'status': 'no_metadata', 'reason': 'No metadata extracted'}
|
||||||
|
|
||||||
tags_added = 0
|
tags_added = 0
|
||||||
|
post_tag = None # Track the post tag for PostMetadata
|
||||||
|
|
||||||
# Update date if earlier
|
# Update date if earlier
|
||||||
new_date = resolve_date_conflict(record.taken_at, enriched.get('taken_at'))
|
new_date = resolve_date_conflict(record.taken_at, enriched.get('taken_at'))
|
||||||
@@ -78,17 +79,62 @@ def apply_sidecar_metadata(self, image_id: int, sidecar_path: str):
|
|||||||
record.tags.append(tag)
|
record.tags.append(tag)
|
||||||
tags_added += 1
|
tags_added += 1
|
||||||
|
|
||||||
|
# Track the post tag for PostMetadata creation
|
||||||
|
if tag_kind == 'post':
|
||||||
|
post_tag = tag
|
||||||
|
|
||||||
|
# Create or update PostMetadata for post tags
|
||||||
|
post_metadata_created = False
|
||||||
|
if post_tag and enriched.get('platform') and enriched.get('post_id'):
|
||||||
|
existing_pm = PostMetadata.query.filter_by(tag_id=post_tag.id).first()
|
||||||
|
if not existing_pm:
|
||||||
|
# Create new PostMetadata record
|
||||||
|
pm = PostMetadata(
|
||||||
|
tag_id=post_tag.id,
|
||||||
|
platform=enriched.get('platform'),
|
||||||
|
post_id=enriched.get('post_id'),
|
||||||
|
artist=enriched.get('artist'),
|
||||||
|
title=enriched.get('post_title'),
|
||||||
|
description=enriched.get('description'),
|
||||||
|
source_url=enriched.get('source_url'),
|
||||||
|
attachment_count=enriched.get('attachment_count', 0),
|
||||||
|
published_at=enriched.get('taken_at'),
|
||||||
|
)
|
||||||
|
db.session.add(pm)
|
||||||
|
post_metadata_created = True
|
||||||
|
log.debug(f"Created PostMetadata for tag {post_tag.name}")
|
||||||
|
else:
|
||||||
|
# Update existing PostMetadata if it has missing fields
|
||||||
|
updated = False
|
||||||
|
if not existing_pm.title and enriched.get('post_title'):
|
||||||
|
existing_pm.title = enriched.get('post_title')
|
||||||
|
updated = True
|
||||||
|
if not existing_pm.description and enriched.get('description'):
|
||||||
|
existing_pm.description = enriched.get('description')
|
||||||
|
updated = True
|
||||||
|
if not existing_pm.source_url and enriched.get('source_url'):
|
||||||
|
existing_pm.source_url = enriched.get('source_url')
|
||||||
|
updated = True
|
||||||
|
# Always update attachment count to get accurate count
|
||||||
|
if enriched.get('attachment_count', 0) > existing_pm.attachment_count:
|
||||||
|
existing_pm.attachment_count = enriched.get('attachment_count', 0)
|
||||||
|
updated = True
|
||||||
|
if updated:
|
||||||
|
log.debug(f"Updated PostMetadata for tag {post_tag.name}")
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
log.info(f"Applied sidecar metadata to {image_id}: "
|
log.info(f"Applied sidecar metadata to {image_id}: "
|
||||||
f"platform={enriched.get('platform')}, tags_added={tags_added}")
|
f"platform={enriched.get('platform')}, tags_added={tags_added}, "
|
||||||
|
f"post_metadata_created={post_metadata_created}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'applied',
|
'status': 'applied',
|
||||||
'platform': enriched.get('platform'),
|
'platform': enriched.get('platform'),
|
||||||
'artist': enriched.get('artist'),
|
'artist': enriched.get('artist'),
|
||||||
'post_id': enriched.get('post_id'),
|
'post_id': enriched.get('post_id'),
|
||||||
'tags_added': tags_added
|
'tags_added': tags_added,
|
||||||
|
'post_metadata_created': post_metadata_created
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
{% elif t.kind == 'series' %}
|
{% elif t.kind == 'series' %}
|
||||||
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
|
{% elif t.kind == 'fandom' %}
|
||||||
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
{% elif t.kind == 'rating' %}
|
{% elif t.kind == 'rating' %}
|
||||||
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="modalTagEditor" class="tag-editor" data-image-id="">
|
<div id="modalTagEditor" class="tag-editor" data-image-id="">
|
||||||
|
<div id="modalSeriesInfo" class="modal-series-info" style="display: none;">
|
||||||
|
<div class="series-info-row">
|
||||||
|
<span class="series-info-label">📚 Page</span>
|
||||||
|
<input type="number" id="modalPageNumber" min="1" class="series-page-input">
|
||||||
|
<span class="series-info-label">of</span>
|
||||||
|
<a id="modalSeriesName" href="#" class="series-info-link"></a>
|
||||||
|
<button id="updatePageBtn" class="btn-small" title="Update page number">Save</button>
|
||||||
|
<span id="pageUpdateStatus" class="page-update-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="modalTagList" class="tags"></div>
|
<div id="modalTagList" class="tags"></div>
|
||||||
<form id="modalTagForm" class="tag-form" autocomplete="off">
|
<form id="modalTagForm" class="tag-form" autocomplete="off">
|
||||||
<div class="tag-form-wrapper">
|
<div class="tag-form-wrapper">
|
||||||
|
|||||||
+158
-1
@@ -29,6 +29,60 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 -->
|
<!-- Gallery Grid with Date Sections -->
|
||||||
<div id="galleryInfinite" class="gallery-infinite">
|
<div id="galleryInfinite" class="gallery-infinite">
|
||||||
{% for year_month_key, year_month_label, images in grouped_images %}
|
{% for year_month_key, year_month_label, images in grouped_images %}
|
||||||
@@ -90,6 +144,22 @@
|
|||||||
<p class="form-help">Common tags across selected images:</p>
|
<p class="form-help">Common tags across selected images:</p>
|
||||||
<div id="commonTagsList" class="tags"></div>
|
<div id="commonTagsList" class="tags"></div>
|
||||||
</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="addToSeriesBtn" 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">
|
<div class="bulk-editor-actions">
|
||||||
<button id="clearSelectionBtn" class="btn secondary-btn">Clear Selection</button>
|
<button id="clearSelectionBtn" class="btn secondary-btn">Clear Selection</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,11 +170,98 @@
|
|||||||
window.galleryState = {
|
window.galleryState = {
|
||||||
cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }},
|
cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }},
|
||||||
hasMore: {{ 'true' if has_more else 'false' }},
|
hasMore: {{ 'true' if has_more else 'false' }},
|
||||||
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }}
|
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }},
|
||||||
|
seriesInfo: {{ series_info | tojson | safe if series_info else 'null' }}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/bulk-select.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 addToSeriesBtn = document.getElementById('addToSeriesBtn');
|
||||||
|
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;
|
||||||
|
addToSeriesBtn.disabled = selectedCount === 0;
|
||||||
|
addToSeriesBtn.textContent = selectedCount > 0
|
||||||
|
? `Add ${selectedCount} to Series`
|
||||||
|
: 'Add to Series';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for selection changes
|
||||||
|
document.addEventListener('selectionChanged', updateAddButtonState);
|
||||||
|
|
||||||
|
// Add selected images to series
|
||||||
|
addToSeriesBtn.addEventListener('click', async () => {
|
||||||
|
if (!window.selectedImages || window.selectedImages.size === 0) return;
|
||||||
|
|
||||||
|
const imageIds = Array.from(window.selectedImages);
|
||||||
|
const startPage = parseInt(startPageInput.value) || 1;
|
||||||
|
|
||||||
|
addToSeriesBtn.disabled = true;
|
||||||
|
addToSeriesBtn.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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block title %}{{ series_name }} - Reader{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="reader-container">
|
||||||
|
<!-- Reader Header -->
|
||||||
|
<div class="reader-header">
|
||||||
|
<div class="reader-header-left">
|
||||||
|
<a href="{{ url_for('main.gallery', tag=tag.name) }}" class="reader-back-btn" title="Back to gallery">
|
||||||
|
<span class="back-arrow">←</span>
|
||||||
|
</a>
|
||||||
|
<h1 class="reader-title">{{ series_name }}</h1>
|
||||||
|
<span class="reader-page-count">{{ total_pages }} pages</span>
|
||||||
|
</div>
|
||||||
|
<div class="reader-header-right">
|
||||||
|
<div class="reader-jump">
|
||||||
|
<label for="pageJumpSelect">Jump to:</label>
|
||||||
|
<select id="pageJumpSelect" class="reader-jump-select">
|
||||||
|
{% for page in pages %}
|
||||||
|
<option value="{{ page.page_number }}" {% if page.page_number == start_page %}selected{% endif %}>
|
||||||
|
Page {{ page.page_number }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button id="toggleNavBtn" class="reader-toggle-nav" title="Toggle navigation panel">
|
||||||
|
<span class="nav-icon">☰</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reader-body">
|
||||||
|
<!-- Navigation Sidebar (toggleable) -->
|
||||||
|
<aside id="readerNav" class="reader-nav">
|
||||||
|
<div class="reader-nav-header">
|
||||||
|
<h3>Pages</h3>
|
||||||
|
<button id="closeNavBtn" class="reader-nav-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="reader-nav-thumbs">
|
||||||
|
{% for page in pages %}
|
||||||
|
<div class="reader-nav-thumb" data-page="{{ page.page_number }}">
|
||||||
|
<img src="{{ url_for('main.serve_image', filename=(page.image.thumb_path or page.image.filepath) | replace('/images/', '')) }}" alt="Page {{ page.page_number }}" loading="lazy">
|
||||||
|
<span class="thumb-page-num">{{ page.page_number }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Reading Area -->
|
||||||
|
<main id="readerMain" class="reader-main">
|
||||||
|
<div id="readerContent" class="reader-content">
|
||||||
|
{% if pages %}
|
||||||
|
{% for page in pages %}
|
||||||
|
<div class="reader-page" id="page-{{ page.page_number }}" data-page="{{ page.page_number }}">
|
||||||
|
<img src="{{ url_for('main.serve_image', filename=page.image.filepath | replace('/images/', '')) }}"
|
||||||
|
alt="Page {{ page.page_number }}"
|
||||||
|
loading="lazy"
|
||||||
|
data-image-id="{{ page.image.id }}">
|
||||||
|
<div class="page-indicator">{{ page.page_number }} / {{ total_pages }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="reader-empty">
|
||||||
|
<p>No pages in this series yet.</p>
|
||||||
|
<a href="{{ url_for('main.gallery', tag=tag.name) }}" class="btn primary-btn">
|
||||||
|
Go to gallery to add pages
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating page indicator -->
|
||||||
|
<div id="floatingPageIndicator" class="floating-page-indicator">
|
||||||
|
<span id="currentPageNum">1</span> / {{ total_pages }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick jump buttons -->
|
||||||
|
<div class="reader-quick-nav">
|
||||||
|
<button id="scrollToTopBtn" class="reader-quick-btn" title="Go to first page">↑ First</button>
|
||||||
|
<button id="scrollToBottomBtn" class="reader-quick-btn" title="Go to last page">Last ↓</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const readerContent = document.getElementById('readerContent');
|
||||||
|
const pageJumpSelect = document.getElementById('pageJumpSelect');
|
||||||
|
const floatingIndicator = document.getElementById('floatingPageIndicator');
|
||||||
|
const currentPageNum = document.getElementById('currentPageNum');
|
||||||
|
const readerNav = document.getElementById('readerNav');
|
||||||
|
const toggleNavBtn = document.getElementById('toggleNavBtn');
|
||||||
|
const closeNavBtn = document.getElementById('closeNavBtn');
|
||||||
|
const navThumbs = document.querySelectorAll('.reader-nav-thumb');
|
||||||
|
const scrollToTopBtn = document.getElementById('scrollToTopBtn');
|
||||||
|
const scrollToBottomBtn = document.getElementById('scrollToBottomBtn');
|
||||||
|
const pages = document.querySelectorAll('.reader-page');
|
||||||
|
|
||||||
|
const startPage = {{ start_page }};
|
||||||
|
let currentPage = startPage;
|
||||||
|
let isNavOpen = false;
|
||||||
|
|
||||||
|
// Scroll to starting page on load
|
||||||
|
if (startPage > 1) {
|
||||||
|
const targetPage = document.getElementById('page-' + startPage);
|
||||||
|
if (targetPage) {
|
||||||
|
setTimeout(() => {
|
||||||
|
targetPage.scrollIntoView({ behavior: 'auto' });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track current page while scrolling
|
||||||
|
function updateCurrentPage() {
|
||||||
|
const scrollTop = readerContent.scrollTop;
|
||||||
|
const viewportHeight = readerContent.clientHeight;
|
||||||
|
const viewportCenter = scrollTop + (viewportHeight / 3);
|
||||||
|
|
||||||
|
let newCurrentPage = 1;
|
||||||
|
pages.forEach(page => {
|
||||||
|
const pageTop = page.offsetTop;
|
||||||
|
const pageBottom = pageTop + page.offsetHeight;
|
||||||
|
if (viewportCenter >= pageTop && viewportCenter < pageBottom) {
|
||||||
|
newCurrentPage = parseInt(page.dataset.page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newCurrentPage !== currentPage) {
|
||||||
|
currentPage = newCurrentPage;
|
||||||
|
currentPageNum.textContent = currentPage;
|
||||||
|
pageJumpSelect.value = currentPage;
|
||||||
|
|
||||||
|
// Update nav thumb highlight
|
||||||
|
navThumbs.forEach(thumb => {
|
||||||
|
thumb.classList.toggle('active', parseInt(thumb.dataset.page) === currentPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update URL without reload
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('page', currentPage);
|
||||||
|
history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled scroll handler
|
||||||
|
let scrollTimeout;
|
||||||
|
readerContent.addEventListener('scroll', () => {
|
||||||
|
if (scrollTimeout) return;
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
updateCurrentPage();
|
||||||
|
scrollTimeout = null;
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Jump to page from select
|
||||||
|
pageJumpSelect.addEventListener('change', (e) => {
|
||||||
|
const pageNum = parseInt(e.target.value);
|
||||||
|
const targetPage = document.getElementById('page-' + pageNum);
|
||||||
|
if (targetPage) {
|
||||||
|
targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle navigation sidebar
|
||||||
|
toggleNavBtn.addEventListener('click', () => {
|
||||||
|
isNavOpen = !isNavOpen;
|
||||||
|
readerNav.classList.toggle('open', isNavOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
closeNavBtn.addEventListener('click', () => {
|
||||||
|
isNavOpen = false;
|
||||||
|
readerNav.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click thumbnail to jump
|
||||||
|
navThumbs.forEach(thumb => {
|
||||||
|
thumb.addEventListener('click', () => {
|
||||||
|
const pageNum = parseInt(thumb.dataset.page);
|
||||||
|
const targetPage = document.getElementById('page-' + pageNum);
|
||||||
|
if (targetPage) {
|
||||||
|
targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
// Close nav on mobile
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
isNavOpen = false;
|
||||||
|
readerNav.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick nav buttons
|
||||||
|
scrollToTopBtn.addEventListener('click', () => {
|
||||||
|
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollToBottomBtn.addEventListener('click', () => {
|
||||||
|
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault();
|
||||||
|
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault();
|
||||||
|
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||||
|
break;
|
||||||
|
case 'PageUp':
|
||||||
|
e.preventDefault();
|
||||||
|
readerContent.scrollBy({ top: -readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||||
|
break;
|
||||||
|
case 'PageDown':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
readerContent.scrollBy({ top: readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
isNavOpen = !isNavOpen;
|
||||||
|
readerNav.classList.toggle('open', isNavOpen);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
updateCurrentPage();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
{%- if active_kind == 'artist' -%}Artists
|
{%- if active_kind == 'artist' -%}Artists
|
||||||
{%- elif active_kind == 'character' -%}Characters
|
{%- elif active_kind == 'character' -%}Characters
|
||||||
{%- elif active_kind == 'series' -%}Series
|
{%- elif active_kind == 'series' -%}Series
|
||||||
|
{%- elif active_kind == 'fandom' -%}Fandoms
|
||||||
{%- elif active_kind == 'rating' -%}Ratings
|
{%- elif active_kind == 'rating' -%}Ratings
|
||||||
{%- elif active_kind == 'archive' -%}Archives
|
{%- elif active_kind == 'archive' -%}Archives
|
||||||
{%- elif active_kind == 'source' -%}Sources
|
{%- elif active_kind == 'source' -%}Sources
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
{%- if active_kind == 'artist' -%}Artists
|
{%- if active_kind == 'artist' -%}Artists
|
||||||
{%- elif active_kind == 'character' -%}Characters
|
{%- elif active_kind == 'character' -%}Characters
|
||||||
{%- elif active_kind == 'series' -%}Series
|
{%- elif active_kind == 'series' -%}Series
|
||||||
|
{%- elif active_kind == 'fandom' -%}Fandoms
|
||||||
{%- elif active_kind == 'rating' -%}Ratings
|
{%- elif active_kind == 'rating' -%}Ratings
|
||||||
{%- elif active_kind == 'archive' -%}Archives
|
{%- elif active_kind == 'archive' -%}Archives
|
||||||
{%- elif active_kind == 'source' -%}Sources
|
{%- elif active_kind == 'source' -%}Sources
|
||||||
@@ -42,6 +44,10 @@
|
|||||||
href="{{ url_for('main.tag_list', kind='series') }}"
|
href="{{ url_for('main.tag_list', kind='series') }}"
|
||||||
aria-current="{{ 'page' if active_kind=='series' else 'false' }}">📺 Series</a>
|
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' }}"
|
<a class="filter-btn {{ 'is-active' if active_kind=='rating' }}"
|
||||||
href="{{ url_for('main.tag_list', kind='rating') }}"
|
href="{{ url_for('main.tag_list', kind='rating') }}"
|
||||||
aria-current="{{ 'page' if active_kind=='rating' else 'false' }}">⚠️ Ratings</a>
|
aria-current="{{ 'page' if active_kind=='rating' else 'false' }}">⚠️ Ratings</a>
|
||||||
@@ -84,6 +90,7 @@
|
|||||||
{%- if tag.kind == 'artist' -%}🎨
|
{%- if tag.kind == 'artist' -%}🎨
|
||||||
{%- elif tag.kind == 'character' -%}👤
|
{%- elif tag.kind == 'character' -%}👤
|
||||||
{%- elif tag.kind == 'series' -%}📺
|
{%- elif tag.kind == 'series' -%}📺
|
||||||
|
{%- elif tag.kind == 'fandom' -%}🎭
|
||||||
{%- elif tag.kind == 'rating' -%}⚠️
|
{%- elif tag.kind == 'rating' -%}⚠️
|
||||||
{%- elif tag.kind == 'archive' -%}🗜️
|
{%- elif tag.kind == 'archive' -%}🗜️
|
||||||
{%- elif tag.kind == 'source' -%}🌐
|
{%- elif tag.kind == 'source' -%}🌐
|
||||||
@@ -122,6 +129,7 @@
|
|||||||
<option value="artist">🎨 Artist</option>
|
<option value="artist">🎨 Artist</option>
|
||||||
<option value="character">👤 Character</option>
|
<option value="character">👤 Character</option>
|
||||||
<option value="series">📺 Series</option>
|
<option value="series">📺 Series</option>
|
||||||
|
<option value="fandom">🎭 Fandom</option>
|
||||||
<option value="rating">⚠️ Rating</option>
|
<option value="rating">⚠️ Rating</option>
|
||||||
<option value="archive">🗜️ Archive</option>
|
<option value="archive">🗜️ Archive</option>
|
||||||
<option value="source">🌐 Source</option>
|
<option value="source">🌐 Source</option>
|
||||||
|
|||||||
@@ -157,7 +157,95 @@ def extract_post_id(metadata: dict) -> Optional[str]:
|
|||||||
|
|
||||||
def extract_post_title(metadata: dict) -> Optional[str]:
|
def extract_post_title(metadata: dict) -> Optional[str]:
|
||||||
"""Extract the post title from metadata."""
|
"""Extract the post title from metadata."""
|
||||||
return metadata.get("title")
|
title = metadata.get("title")
|
||||||
|
# Return None for empty strings
|
||||||
|
if title and isinstance(title, str) and title.strip():
|
||||||
|
return title.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_description(metadata: dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract the post description/content from metadata.
|
||||||
|
|
||||||
|
Platform-specific fields:
|
||||||
|
- Patreon: 'content' (may be HTML)
|
||||||
|
- SubscribeStar: 'content' (may be HTML)
|
||||||
|
- HentaiFoundry: 'description' (plain text)
|
||||||
|
"""
|
||||||
|
# Try description first (HentaiFoundry uses this)
|
||||||
|
if "description" in metadata:
|
||||||
|
desc = metadata["description"]
|
||||||
|
if desc and isinstance(desc, str) and desc.strip():
|
||||||
|
return desc.strip()
|
||||||
|
|
||||||
|
# Try content (Patreon/SubscribeStar use this)
|
||||||
|
if "content" in metadata:
|
||||||
|
content = metadata["content"]
|
||||||
|
if content and isinstance(content, str) and content.strip():
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
# Try teaser_text as fallback (Patreon)
|
||||||
|
if "teaser_text" in metadata:
|
||||||
|
teaser = metadata["teaser_text"]
|
||||||
|
if teaser and isinstance(teaser, str) and teaser.strip():
|
||||||
|
return teaser.strip()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_source_url(metadata: dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract the URL to the original post.
|
||||||
|
|
||||||
|
Platform-specific fields:
|
||||||
|
- Patreon: 'url' (direct post link)
|
||||||
|
- SubscribeStar: construct from author_name and post_id
|
||||||
|
- HentaiFoundry: construct from user and index
|
||||||
|
"""
|
||||||
|
platform = extract_platform(metadata)
|
||||||
|
|
||||||
|
# Direct URL field (Patreon)
|
||||||
|
if "url" in metadata:
|
||||||
|
url = metadata["url"]
|
||||||
|
if url and isinstance(url, str) and url.startswith("http"):
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Construct URL for SubscribeStar
|
||||||
|
if platform == "subscribestar":
|
||||||
|
author = metadata.get("author_name")
|
||||||
|
post_id = metadata.get("post_id")
|
||||||
|
if author and post_id:
|
||||||
|
return f"https://subscribestar.adult/{author}/posts/{post_id}"
|
||||||
|
|
||||||
|
# Construct URL for HentaiFoundry
|
||||||
|
if platform == "hentaifoundry":
|
||||||
|
user = metadata.get("user") or metadata.get("artist")
|
||||||
|
index = metadata.get("index")
|
||||||
|
if user and index:
|
||||||
|
return f"https://www.hentai-foundry.com/pictures/user/{user}/{index}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_attachment_count(metadata: dict) -> int:
|
||||||
|
"""
|
||||||
|
Extract the number of attachments/images in the post.
|
||||||
|
"""
|
||||||
|
# Patreon: check images array or post_metadata.image_order
|
||||||
|
if "images" in metadata and isinstance(metadata["images"], list):
|
||||||
|
return len(metadata["images"])
|
||||||
|
|
||||||
|
if "post_metadata" in metadata:
|
||||||
|
pm = metadata["post_metadata"]
|
||||||
|
if isinstance(pm, dict) and "image_order" in pm:
|
||||||
|
return len(pm["image_order"])
|
||||||
|
|
||||||
|
# Default to 1 if we have a file
|
||||||
|
if "filename" in metadata or "file" in metadata:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def extract_date(metadata: dict) -> Optional[datetime]:
|
def extract_date(metadata: dict) -> Optional[datetime]:
|
||||||
@@ -336,6 +424,9 @@ def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = Non
|
|||||||
"artist": str or None,
|
"artist": str or None,
|
||||||
"post_id": str or None,
|
"post_id": str or None,
|
||||||
"post_title": str or None,
|
"post_title": str or None,
|
||||||
|
"description": str or None,
|
||||||
|
"source_url": str or None,
|
||||||
|
"attachment_count": int,
|
||||||
"taken_at": datetime or None,
|
"taken_at": datetime or None,
|
||||||
"tags": [{"name": str, "kind": str}, ...],
|
"tags": [{"name": str, "kind": str}, ...],
|
||||||
"raw_metadata": dict or None (the full sidecar content)
|
"raw_metadata": dict or None (the full sidecar content)
|
||||||
@@ -346,6 +437,9 @@ def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = Non
|
|||||||
"artist": None,
|
"artist": None,
|
||||||
"post_id": None,
|
"post_id": None,
|
||||||
"post_title": None,
|
"post_title": None,
|
||||||
|
"description": None,
|
||||||
|
"source_url": None,
|
||||||
|
"attachment_count": 0,
|
||||||
"taken_at": None,
|
"taken_at": None,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"raw_metadata": None,
|
"raw_metadata": None,
|
||||||
@@ -367,6 +461,9 @@ def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = Non
|
|||||||
result["artist"] = extract_artist(metadata)
|
result["artist"] = extract_artist(metadata)
|
||||||
result["post_id"] = extract_post_id(metadata)
|
result["post_id"] = extract_post_id(metadata)
|
||||||
result["post_title"] = extract_post_title(metadata)
|
result["post_title"] = extract_post_title(metadata)
|
||||||
|
result["description"] = extract_description(metadata)
|
||||||
|
result["source_url"] = extract_source_url(metadata)
|
||||||
|
result["attachment_count"] = extract_attachment_count(metadata)
|
||||||
|
|
||||||
# Extract date and resolve conflicts with existing
|
# Extract date and resolve conflicts with existing
|
||||||
sidecar_date = extract_date(metadata)
|
sidecar_date = extract_date(metadata)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Add PostMetadata table for storing post information from external platforms
|
||||||
|
|
||||||
|
Revision ID: d26012201
|
||||||
|
Revises: c26012001
|
||||||
|
Create Date: 2026-01-22
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd26012201'
|
||||||
|
down_revision = 'c26012001'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create post_metadata table
|
||||||
|
op.create_table(
|
||||||
|
'post_metadata',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('platform', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('post_id', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('artist', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('title', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('source_url', sa.String(length=1024), nullable=True),
|
||||||
|
sa.Column('attachment_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('published_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('tag_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_post_metadata_platform'), 'post_metadata', ['platform'], unique=False)
|
||||||
|
op.create_index('ix_post_metadata_platform_post_id', 'post_metadata', ['platform', 'post_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('ix_post_metadata_platform_post_id', table_name='post_metadata')
|
||||||
|
op.drop_index(op.f('ix_post_metadata_platform'), table_name='post_metadata')
|
||||||
|
op.drop_table('post_metadata')
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Add SeriesPage table for ordered series/comic reading
|
||||||
|
|
||||||
|
Revision ID: e26012202
|
||||||
|
Revises: d26012201
|
||||||
|
Create Date: 2026-01-22
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'e26012202'
|
||||||
|
down_revision = 'd26012201'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create series_page table
|
||||||
|
op.create_table(
|
||||||
|
'series_page',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('series_tag_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('image_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('page_number', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('image_id'), # Image can only be in one series
|
||||||
|
sa.UniqueConstraint('series_tag_id', 'page_number', name='uq_series_page_number')
|
||||||
|
)
|
||||||
|
op.create_index('ix_series_page_series_tag', 'series_page', ['series_tag_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('ix_series_page_series_tag', table_name='series_page')
|
||||||
|
op.drop_table('series_page')
|
||||||
+407
@@ -0,0 +1,407 @@
|
|||||||
|
# ImageRepo - Codebase Summary
|
||||||
|
|
||||||
|
A Flask-based image repository with Celery task processing for importing, organizing, and managing images/videos with tagging, duplicate detection, and Gallery-DL metadata integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Flask Web │ │ Celery Worker │ │ Celery Beat │
|
||||||
|
│ (port 5000) │ │ (task queues) │ │ (scheduler) │
|
||||||
|
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||||
|
│ │ │
|
||||||
|
└───────────────────────┼───────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────┼──────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌─────▼─────┐ ┌──────▼──────┐ ┌─────▼─────┐
|
||||||
|
│ PostgreSQL│ │ Redis │ │ /images │
|
||||||
|
│ (database)│ │ (broker) │ │ /import │
|
||||||
|
└───────────┘ └─────────────┘ └───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Core Application Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `run.py` | Entry point - creates Flask app via `create_app()` |
|
||||||
|
| `config.py` | Configuration class with DB, Redis, Celery settings |
|
||||||
|
| `docker-compose.yml` | Container orchestration for all services |
|
||||||
|
|
||||||
|
### Application Package (`app/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app/__init__.py:56-86` | Flask app factory `create_app()`, CLI commands, initializes extensions |
|
||||||
|
| `app/main.py` | Blueprint with all HTTP routes and API endpoints |
|
||||||
|
| `app/models.py` | SQLAlchemy models for database schema |
|
||||||
|
| `app/celery_app.py` | Celery configuration and task routing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Models (`app/models.py`)
|
||||||
|
|
||||||
|
### ImageRecord (lines 19-40)
|
||||||
|
Primary model for images/videos.
|
||||||
|
```
|
||||||
|
Fields: id, filename, filepath, thumb_path, hash (SHA256), perceptual_hash (256-bit),
|
||||||
|
file_size, width, height, format, camera_model, taken_at, imported_at, is_thumbnail
|
||||||
|
Relationships: tags (many-to-many via image_tags)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag (lines 42-51)
|
||||||
|
Tagging system for organization.
|
||||||
|
```
|
||||||
|
Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|rating)
|
||||||
|
Relationships: images (many-to-many)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ArchiveRecord (lines 53-66)
|
||||||
|
Tracks processed archives to prevent re-importing.
|
||||||
|
```
|
||||||
|
Fields: id, filename, file_size, hash, imported_at, artist, tag_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppSettings (lines 69-78)
|
||||||
|
Key-value store for application configuration.
|
||||||
|
```
|
||||||
|
Fields: id, key, value, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### ImportBatch (lines 81-101)
|
||||||
|
Groups import tasks for batch tracking.
|
||||||
|
```
|
||||||
|
Fields: id, source_directory, status, total_files, processed_files,
|
||||||
|
imported_count, skipped_count, failed_count, started_at, completed_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostMetadata (lines 104-133)
|
||||||
|
Stores metadata about posts from external platforms (Patreon, SubscribeStar, HentaiFoundry).
|
||||||
|
```
|
||||||
|
Fields: id, tag_id (FK to post tag), platform, post_id, artist,
|
||||||
|
title, description (may contain HTML), source_url,
|
||||||
|
attachment_count, published_at, created_at, updated_at
|
||||||
|
Relationships: tag (one-to-one with Tag where kind='post')
|
||||||
|
```
|
||||||
|
|
||||||
|
### SeriesPage (lines 136-168)
|
||||||
|
Links images to series tags with page numbers for ordered reading.
|
||||||
|
```
|
||||||
|
Fields: id, series_tag_id (FK to tag), image_id (FK to image, unique),
|
||||||
|
page_number, created_at, updated_at
|
||||||
|
Constraints: unique(series_tag_id, page_number), unique(image_id)
|
||||||
|
Relationships: series_tag, image
|
||||||
|
```
|
||||||
|
|
||||||
|
### ImportTask (lines 171-222)
|
||||||
|
Individual file import task tracking.
|
||||||
|
```
|
||||||
|
Fields: id, source_path, file_hash, file_size, task_type, status, celery_task_id,
|
||||||
|
context (JSON), batch_id, result_image_id, error_message, retry_count, timestamps
|
||||||
|
Status values: pending, queued, processing, complete, failed, skipped
|
||||||
|
Task types: scan, import_image, import_archive, thumbnail, sidecar
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Celery Tasks (`app/tasks/`)
|
||||||
|
|
||||||
|
### Scan Tasks (`app/tasks/scan.py`)
|
||||||
|
|
||||||
|
| Function | Line | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `scan_directory()` | 29-154 | Walks `/import` directory, creates ImportTask records, queues import tasks |
|
||||||
|
| `_create_task_if_new()` | 157-228 | Creates ImportTask if file not already processed |
|
||||||
|
| `recover_interrupted_tasks()` | 231-302 | Re-queues stuck tasks (called periodically by Beat) |
|
||||||
|
| `update_batch_stats()` | 305-349 | Updates ImportBatch statistics |
|
||||||
|
|
||||||
|
### Import Tasks (`app/tasks/import_file.py`)
|
||||||
|
|
||||||
|
| Function | Line | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `import_media_file()` | 23-252 | Main import task - filters, deduplicates, copies, creates thumbnail |
|
||||||
|
| `import_archive()` | 255-407 | Extracts archive, queues child import tasks |
|
||||||
|
| `_fail_task()` | 410-425 | Marks task as failed |
|
||||||
|
| `_skip_task()` | 428-443 | Marks task as skipped |
|
||||||
|
| `_add_artist_tag()` | 446-459 | Adds artist:name tag to image |
|
||||||
|
| `_supersede_existing()` | 472-539 | Replaces smaller image with larger version |
|
||||||
|
|
||||||
|
### Thumbnail Tasks (`app/tasks/thumbnail.py`)
|
||||||
|
|
||||||
|
| Function | Line | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `generate_thumbnail_task()` | 18-69 | Generate thumbnail for single image/video |
|
||||||
|
| `regenerate_all_thumbnails()` | 72-108 | Bulk regenerate all thumbnails |
|
||||||
|
| `regenerate_missing_thumbnails()` | 111-153 | Generate only missing thumbnails |
|
||||||
|
|
||||||
|
### Sidecar Tasks (`app/tasks/sidecar.py`)
|
||||||
|
|
||||||
|
| Function | Line | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `apply_sidecar_metadata()` | 18-97 | Apply Gallery-DL JSON metadata to image |
|
||||||
|
| `scan_and_match_sidecars()` | 100-147 | Find and match sidecar files to images |
|
||||||
|
| `reprocess_sidecars_for_tag()` | 150-181 | Re-run sidecar extraction for tagged images |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utility Functions (`app/utils/`)
|
||||||
|
|
||||||
|
### Image Importer (`app/utils/image_importer.py`)
|
||||||
|
|
||||||
|
| Function | Line | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `load_import_settings()` | 50-88 | Load filter settings from DB/file/env |
|
||||||
|
| `is_mostly_transparent()` | 91-146 | Check if image is mostly transparent |
|
||||||
|
| `is_mostly_single_color()` | 149-215 | Check if image is solid color |
|
||||||
|
| `supersede_image()` | 218-275 | Replace smaller image with larger version |
|
||||||
|
| `extract_archive_resilient()` | 359-384 | Extract archive using unar/7z fallback |
|
||||||
|
| `build_hashed_dest_path()` | 391-408 | Generate content-addressed file path |
|
||||||
|
| `generate_thumbnail()` | 415-500 | Create thumbnail for image |
|
||||||
|
| `generate_video_thumbnail_mirrored()` | 524-532 | Create thumbnail for video |
|
||||||
|
| `transcode_video_to_mp4()` | 535-609 | Transcode video to H.264 MP4 |
|
||||||
|
| `needs_transcode()` | 612-615 | Check if video needs transcoding |
|
||||||
|
| `calculate_hash()` | 618-623 | SHA256 content hash |
|
||||||
|
| `calculate_perceptual_hash()` | 626-643 | 256-bit pHash for similarity |
|
||||||
|
| `find_similar_image()` | 646-678 | Find similar images by pHash |
|
||||||
|
| `extract_metadata()` | 691-754 | Extract EXIF/video metadata |
|
||||||
|
| `is_first_volume()` | 788-823 | Check if archive is first volume of multi-part |
|
||||||
|
| `compute_next_archive_tag_name()` | 825-890 | Generate unique archive:artist/NNNN tag |
|
||||||
|
|
||||||
|
### Metadata Enrichment (`app/utils/metadata_enrichment.py`)
|
||||||
|
|
||||||
|
| Function | Line | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `find_sidecar_json()` | 27-83 | Find Gallery-DL sidecar JSON for image |
|
||||||
|
| `load_sidecar_metadata()` | 86-93 | Parse sidecar JSON file |
|
||||||
|
| `extract_platform()` | 100-117 | Get platform from metadata |
|
||||||
|
| `extract_artist()` | 120-136 | Get artist/creator name |
|
||||||
|
| `extract_post_id()` | 139-155 | Get post ID |
|
||||||
|
| `extract_post_title()` | 158-164 | Get post title |
|
||||||
|
| `extract_description()` | 167-194 | Get post description/content (HTML or plain text) |
|
||||||
|
| `extract_source_url()` | 197-228 | Get URL to original post (constructs for SS/HF) |
|
||||||
|
| `extract_attachment_count()` | 231-248 | Get number of attachments in post |
|
||||||
|
| `extract_date()` | 251-271 | Extract publication date |
|
||||||
|
| `generate_source_tags()` | 338-369 | Generate source:platform and post:... tags |
|
||||||
|
| `resolve_date_conflict()` | 376-405 | Keep earliest date |
|
||||||
|
| `enrich_from_sidecar()` | 412-470 | Main enrichment function (returns platform, artist, title, description, source_url, etc.) |
|
||||||
|
|
||||||
|
### Version Migration (`app/utils/version_migration.py`)
|
||||||
|
|
||||||
|
| Function | Line | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `get_setting()` / `set_setting()` | 43-74 | Read/write AppSettings |
|
||||||
|
| `get_setting_int/float/bool()` | 77-104 | Typed setting getters |
|
||||||
|
| `get_import_settings()` | 111-122 | Load import filter settings from DB |
|
||||||
|
| `calculate_phash_256()` | 129-139 | Calculate 256-bit perceptual hash |
|
||||||
|
| `recompute_all_phashes()` | 146-218 | Batch recompute all pHashes |
|
||||||
|
| `check_and_run_migrations()` | 221-268 | Run data migrations on version change |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Routes (`app/main.py`)
|
||||||
|
|
||||||
|
### Page Routes
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET /` | 18-30 | Showcase - random images masonry |
|
||||||
|
| `GET /gallery` | 80-124 | Main gallery with infinite scroll |
|
||||||
|
| `GET /tags` | 391-475 | Tag explorer (filterable by kind) |
|
||||||
|
| `GET /artists` | 477-480 | Redirect to tag_list?kind=artist |
|
||||||
|
| `GET /settings` | 488-490 | Settings page |
|
||||||
|
|
||||||
|
### Gallery API
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET /api/random-images` | 33-77 | Random images for showcase shuffle |
|
||||||
|
| `GET /api/gallery/scroll` | 188-259 | Infinite scroll pagination |
|
||||||
|
| `GET /api/gallery/timeline` | 262-316 | Year-month groups for sidebar |
|
||||||
|
| `GET /api/gallery/jump` | 319-388 | Jump to specific year-month |
|
||||||
|
| `GET /images/<path:filename>` | 127-129 | Serve image files |
|
||||||
|
|
||||||
|
### Tag API
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET /api/tags/search` | 497-537 | Tag autocomplete search |
|
||||||
|
| `GET /image/<id>/tags` | 540-543 | List tags for image |
|
||||||
|
| `POST /image/<id>/tags/add` | 546-573 | Add tag to image |
|
||||||
|
| `POST /image/<id>/tags/remove` | 576-587 | Remove tag from image |
|
||||||
|
| `GET /api/tag/<id>` | 594-605 | Get tag details |
|
||||||
|
| `GET /api/post-metadata/<id>` | 608-651 | Get post metadata for a post tag |
|
||||||
|
| `GET /api/post-metadata/by-tag-name/<name>` | 654-685 | Get post metadata by tag name |
|
||||||
|
| `POST /api/tag/<id>/update` | 688-764 | Update/merge tag |
|
||||||
|
| `POST /api/tag/<id>/delete` | 767-776 | Delete tag |
|
||||||
|
| `GET /api/tag/<id>/image-count` | 842-847 | Count images with tag |
|
||||||
|
|
||||||
|
### Bulk Operations
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `POST /api/delete-by-tag` | 703-759 | Delete all images with tag |
|
||||||
|
| `POST /api/images/common-tags` | 1394-1425 | Get common tags across images |
|
||||||
|
| `POST /api/images/bulk-add-tag` | 1428-1474 | Add tag to multiple images |
|
||||||
|
| `POST /api/images/bulk-remove-tag` | 1477-1514 | Remove tag from multiple images |
|
||||||
|
|
||||||
|
### Series / Reader API
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET /read/<tag_id>` | 1620-1650 | Reader view for a series (vertical scroll) |
|
||||||
|
| `GET /api/series/<tag_id>/pages` | 1653-1685 | Get all pages in a series |
|
||||||
|
| `POST /api/series/<tag_id>/pages/add` | 1688-1737 | Add single image to series with page number |
|
||||||
|
| `POST /api/series/<tag_id>/pages/bulk-add` | 1740-1800 | Add multiple images with sequential page numbers |
|
||||||
|
| `POST /api/series/page/<page_id>/update` | 1803-1828 | Update a page's page number |
|
||||||
|
| `POST /api/series/page/<page_id>/delete` | 1831-1841 | Remove image from series |
|
||||||
|
| `GET /api/image/<image_id>/series-info` | 1844-1858 | Check if image is in a series |
|
||||||
|
|
||||||
|
### Import Queue API
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `POST /api/import/trigger` | 961-981 | Start directory scan |
|
||||||
|
| `GET /api/import/status` | 984-1042 | Queue status and recent tasks |
|
||||||
|
| `GET /api/import/task/<id>` | 1045-1082 | Task details |
|
||||||
|
| `POST /api/import/retry-failed` | 1085-1122 | Retry failed tasks |
|
||||||
|
| `POST /api/import/clear-completed` | 1125-1144 | Clear completed tasks |
|
||||||
|
| `GET /api/import/batches` | 1222-1250 | List import batches |
|
||||||
|
|
||||||
|
### Settings API
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET /api/import-settings` | 774-788 | Get import filter settings |
|
||||||
|
| `POST /api/import-settings` | 791-821 | Save import filter settings |
|
||||||
|
| `GET /api/filter-scan` | 828-902 | Scan images against filters |
|
||||||
|
| `POST /api/filter-delete` | 905-954 | Delete filtered images |
|
||||||
|
|
||||||
|
### Thumbnail API
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `POST /api/thumbnails/regenerate` | 1147-1164 | Regenerate all thumbnails |
|
||||||
|
| `POST /api/thumbnails/regenerate-missing` | 1167-1183 | Generate missing thumbnails |
|
||||||
|
|
||||||
|
### Celery Status
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `GET /api/celery/status` | 1186-1219 | Worker and queue status |
|
||||||
|
|
||||||
|
### Duplicate Detection
|
||||||
|
|
||||||
|
| Route | Line | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `POST /api/duplicates/scan` | 1257-1343 | Scan for similar images by pHash |
|
||||||
|
| `POST /api/duplicates/delete` | 1346-1387 | Delete duplicate images |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Templates (`app/templates/`)
|
||||||
|
|
||||||
|
| Template | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `layout.html` | Base template with navbar |
|
||||||
|
| `showcase.html` | Random image masonry view |
|
||||||
|
| `gallery.html` | Main gallery with infinite scroll, timeline sidebar, bulk editor, series editor |
|
||||||
|
| `reader.html` | Series reader with vertical scroll, page jump, thumbnail navigation |
|
||||||
|
| `tags_list.html` | Tag browser with preview images |
|
||||||
|
| `settings.html` | Import settings, deletion tools, duplicate detection |
|
||||||
|
| `_gallery_item.html` | Single gallery item partial |
|
||||||
|
| `_gallery_modal.html` | Image modal with tag editor |
|
||||||
|
| `_gallery_grid.html` | Gallery grid partial |
|
||||||
|
| `_pagination.html` | Pagination controls |
|
||||||
|
|
||||||
|
### JavaScript Files (`app/static/js/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `gallery-infinite.js` | Infinite scroll, timeline navigation |
|
||||||
|
| `modal-pagination.js` | Modal image navigation |
|
||||||
|
| `bulk-select.js` | Multi-select and bulk tag editing |
|
||||||
|
| `tag-editor.js` | Tag autocomplete and editing |
|
||||||
|
| `showcase.js` | Showcase shuffle functionality |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Services
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `redis` | Message broker for Celery |
|
||||||
|
| `postgres` | PostgreSQL database |
|
||||||
|
| `web` | Flask application (port 5000) |
|
||||||
|
| `celery-worker` | Task processor (queues: scan, import, thumbnail, sidecar, default) |
|
||||||
|
| `celery-beat` | Periodic task scheduler |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### Content-Addressed Storage
|
||||||
|
Files are stored with hash suffix: `{filename}__{hash[:10]}.{ext}`
|
||||||
|
|
||||||
|
### Perceptual Hashing
|
||||||
|
256-bit pHash for duplicate detection (threshold default: 10)
|
||||||
|
|
||||||
|
### Tag Kinds
|
||||||
|
- `artist` - Artist/creator (prefix: `artist:name`)
|
||||||
|
- `archive` - Archive source (prefix: `archive:artist/NNNN`)
|
||||||
|
- `source` - Platform source (prefix: `source:platform`)
|
||||||
|
- `post` - Post reference (prefix: `post:platform:artist:id`)
|
||||||
|
- `user` - User-created tags (no prefix)
|
||||||
|
- `character`, `series`, `rating` - Content tags
|
||||||
|
|
||||||
|
### Import Pipeline
|
||||||
|
1. `scan_directory` walks `/import/{artist}/` folders
|
||||||
|
2. Creates ImportTask for each file
|
||||||
|
3. `import_media_file` or `import_archive` processes file
|
||||||
|
4. Applies filters (dimensions, transparency, duplicates)
|
||||||
|
5. Copies to `/images/{artist}/` with hash suffix
|
||||||
|
6. Generates thumbnail
|
||||||
|
7. Applies sidecar metadata if found
|
||||||
|
8. Creates ImageRecord with tags
|
||||||
|
|
||||||
|
### Sidecar Metadata
|
||||||
|
Gallery-DL JSON files provide:
|
||||||
|
- Platform (patreon, hentaifoundry, etc.)
|
||||||
|
- Artist name
|
||||||
|
- Post ID and title
|
||||||
|
- Publication date
|
||||||
|
- Source URL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `DB_USER` | imagerepo | PostgreSQL user |
|
||||||
|
| `DB_PASS` | postgres | PostgreSQL password |
|
||||||
|
| `DB_HOST` | postgres | PostgreSQL host |
|
||||||
|
| `DB_NAME` | imagerepo | Database name |
|
||||||
|
| `CELERY_BROKER_URL` | redis://redis:6379/0 | Redis broker |
|
||||||
|
| `CELERY_WORKER_CONCURRENCY` | 2 | Worker processes |
|
||||||
|
| `IMPORT_EVERY_SECONDS` | 28800 | Auto-scan interval (8h) |
|
||||||
|
| `IMPORT_MIN_WIDTH` | 0 | Minimum image width |
|
||||||
|
| `IMPORT_MIN_HEIGHT` | 0 | Minimum image height |
|
||||||
|
| `IMPORT_SKIP_TRANSPARENT` | false | Skip transparent images |
|
||||||
|
| `IMPORT_TRANSPARENCY_THRESHOLD` | 0.9 | Transparency % threshold |
|
||||||
|
| `IMPORT_PHASH_THRESHOLD` | 10 | pHash similarity threshold |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask version-check # Run data migrations
|
||||||
|
flask celery-worker # Start Celery worker
|
||||||
|
flask celery-beat # Start Celery Beat scheduler
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user