implement post metadata import and views. implemented series paging and reader view.

This commit is contained in:
Bryan Van Deusen
2026-01-22 22:19:11 -05:00
parent f41acb40f6
commit a05aee5a07
16 changed files with 2298 additions and 8 deletions
+356 -3
View File
@@ -84,6 +84,8 @@ def gallery():
Server renders initial batch, then JS takes over for infinite scroll.
Optional tag filter via ?tag=artist:NAME or any tag name.
"""
from app.models import PostMetadata
initial_limit = 30
tag_name = request.args.get('tag')
@@ -91,8 +93,11 @@ def gallery():
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
# Optional tag filter
active_tag_obj = None
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
images = q.order_by(
@@ -115,12 +120,42 @@ def gallery():
if last_dt:
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(
'gallery.html',
grouped_images=grouped_images,
active_tag=tag_name,
active_tag_obj=active_tag_obj,
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")
def update_tag(tag_id):
"""
@@ -627,7 +729,7 @@ def update_tag(tag_id):
return jsonify(ok=False, error="Name is required"), 400
# 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:
# Remove any existing prefix from name if present
if ":" in new_name:
@@ -1512,3 +1614,254 @@ def bulk_remove_tag():
'ok': True,
'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
})
+63
View File
@@ -101,6 +101,69 @@ class ImportBatch(db.Model):
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):
"""
Tracks the state of each file being processed through the import pipeline.
+42
View File
@@ -112,6 +112,7 @@
updateCount();
loadCommonTags();
dispatchSelectionChanged();
}
function clearSelection() {
@@ -121,6 +122,7 @@
});
updateCount();
updateCommonTagsDisplay();
dispatchSelectionChanged();
}
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() {
// Re-apply selected class to any newly loaded images that are in selection
document.querySelectorAll('.img-clickable').forEach(item => {
@@ -416,6 +451,7 @@
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
@@ -435,4 +471,10 @@
} else {
init();
}
// Expose global API for other scripts (like series editor)
window.selectedImages = state.selectedIds;
window.clearSelection = clearSelection;
window.enableSelectMode = enableSelectMode;
window.disableSelectMode = disableSelectMode;
})();
+105
View File
@@ -14,6 +14,14 @@ document.addEventListener('DOMContentLoaded', () => {
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
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
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
@@ -53,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => {
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
@@ -87,6 +96,100 @@ document.addEventListener('DOMContentLoaded', () => {
// 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) {
const fd = new FormData();
fd.append('name', name);
@@ -299,6 +402,7 @@ document.addEventListener('DOMContentLoaded', () => {
// NEW: load tags for this image into the modal editor
setEditorImageId(imageId);
loadTags(imageId);
loadSeriesInfo(imageId);
}
function openModal(index) {
@@ -324,6 +428,7 @@ document.addEventListener('DOMContentLoaded', () => {
// clear tag UI
setEditorImageId('');
renderTags([]);
hideSeriesInfo();
history.replaceState({}, '', window.location.pathname + window.location.search);
}
+1
View File
@@ -286,6 +286,7 @@
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
+679
View File
@@ -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 {
margin: 0 0 0.5rem 0;
min-height: 28px;
@@ -1407,6 +1464,261 @@ select.form-input optgroup {
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-infinite {
min-height: 50vh;
@@ -1703,6 +2015,21 @@ body.select-mode .gallery-infinite-container {
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 {
color: var(--text-muted);
@@ -1727,3 +2054,355 @@ body.select-mode .gallery-infinite-container {
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
View File
@@ -10,7 +10,7 @@ from datetime import datetime
from app.celery_app import celery
from app import db
from app.models import ImageRecord, Tag
from app.models import ImageRecord, Tag, PostMetadata
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'}
tags_added = 0
post_tag = None # Track the post tag for PostMetadata
# Update date if earlier
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)
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()
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 {
'status': 'applied',
'platform': enriched.get('platform'),
'artist': enriched.get('artist'),
'post_id': enriched.get('post_id'),
'tags_added': tags_added
'tags_added': tags_added,
'post_metadata_created': post_metadata_created
}
except Exception as e:
+2
View File
@@ -22,6 +22,8 @@
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'series' %}
{{ 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' %}
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% else %}
+10
View File
@@ -12,6 +12,16 @@
</div>
<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>
<form id="modalTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
+158 -1
View File
@@ -29,6 +29,60 @@
{% endif %}
</div>
{% if post_metadata %}
<!-- Post Info Header -->
<div class="post-info-header">
<div class="post-info-content">
{% if post_metadata.title %}
<h2 class="post-title">{{ post_metadata.title }}</h2>
{% endif %}
<div class="post-meta">
{% if post_metadata.artist %}
<span class="post-artist">{{ post_metadata.artist }}</span>
{% endif %}
{% if post_metadata.platform %}
<span class="post-platform post-platform-{{ post_metadata.platform }}">{{ post_metadata.platform }}</span>
{% endif %}
{% if post_metadata.published_at %}
<span class="post-date">{{ post_metadata.published_at.strftime('%B %d, %Y') }}</span>
{% endif %}
{% if post_metadata.attachment_count > 1 %}
<span class="post-count">{{ post_metadata.attachment_count }} images</span>
{% endif %}
</div>
{% if post_metadata.description %}
<div class="post-description">
{{ post_metadata.description | safe }}
</div>
{% endif %}
{% if post_metadata.source_url %}
<a href="{{ post_metadata.source_url }}" target="_blank" rel="noopener noreferrer" class="post-source-link">
View original post
</a>
{% endif %}
</div>
</div>
{% endif %}
{% if series_info %}
<!-- Series Header -->
<div class="series-header">
<div class="series-header-content">
<div class="series-header-left">
<h2 class="series-title">{{ series_info.name }}</h2>
<span id="seriesPageCount" class="series-page-count">{{ series_info.page_count }} pages</span>
</div>
<div class="series-header-actions">
{% if series_info.page_count > 0 %}
<a href="{{ url_for('main.read_series', tag_id=series_info.tag_id) }}" class="btn primary-btn">
Read Series
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Gallery Grid with Date Sections -->
<div id="galleryInfinite" class="gallery-infinite">
{% for year_month_key, year_month_label, images in grouped_images %}
@@ -90,6 +144,22 @@
<p class="form-help">Common tags across selected images:</p>
<div id="commonTagsList" class="tags"></div>
</div>
{% if series_info %}
<div class="bulk-editor-section bulk-editor-series">
<h4>📚 Add to Series</h4>
<p class="form-help">Add selected images as pages to "{{ series_info.name }}"</p>
<div class="series-editor-controls">
<div class="series-editor-input">
<label for="startPageInput">Starting page:</label>
<input type="number" id="startPageInput" value="{{ series_info.page_count + 1 }}" min="1" class="form-input">
</div>
<button id="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">
<button id="clearSelectionBtn" class="btn secondary-btn">Clear Selection</button>
</div>
@@ -100,11 +170,98 @@
window.galleryState = {
cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }},
hasMore: {{ 'true' if has_more else 'false' }},
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }}
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }},
seriesInfo: {{ series_info | tojson | safe if series_info else 'null' }}
};
</script>
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal-pagination.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 %}
+234
View File
@@ -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">&larr;</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">&#9776;</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">&times;</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">&uarr; First</button>
<button id="scrollToBottomBtn" class="reader-quick-btn" title="Go to last page">Last &darr;</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 %}
+8
View File
@@ -3,6 +3,7 @@
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'fandom' -%}Fandoms
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'source' -%}Sources
@@ -16,6 +17,7 @@
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'fandom' -%}Fandoms
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'source' -%}Sources
@@ -42,6 +44,10 @@
href="{{ url_for('main.tag_list', kind='series') }}"
aria-current="{{ 'page' if active_kind=='series' else 'false' }}">📺 Series</a>
<a class="filter-btn {{ 'is-active' if active_kind=='fandom' }}"
href="{{ url_for('main.tag_list', kind='fandom') }}"
aria-current="{{ 'page' if active_kind=='fandom' else 'false' }}">🎭 Fandoms</a>
<a class="filter-btn {{ 'is-active' if active_kind=='rating' }}"
href="{{ url_for('main.tag_list', kind='rating') }}"
aria-current="{{ 'page' if active_kind=='rating' else 'false' }}">⚠️ Ratings</a>
@@ -84,6 +90,7 @@
{%- if tag.kind == 'artist' -%}🎨
{%- elif tag.kind == 'character' -%}👤
{%- elif tag.kind == 'series' -%}📺
{%- elif tag.kind == 'fandom' -%}🎭
{%- elif tag.kind == 'rating' -%}⚠️
{%- elif tag.kind == 'archive' -%}🗜️
{%- elif tag.kind == 'source' -%}🌐
@@ -122,6 +129,7 @@
<option value="artist">🎨 Artist</option>
<option value="character">👤 Character</option>
<option value="series">📺 Series</option>
<option value="fandom">🎭 Fandom</option>
<option value="rating">⚠️ Rating</option>
<option value="archive">🗜️ Archive</option>
<option value="source">🌐 Source</option>
+98 -1
View File
@@ -157,7 +157,95 @@ def extract_post_id(metadata: dict) -> Optional[str]:
def extract_post_title(metadata: dict) -> Optional[str]:
"""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]:
@@ -336,6 +424,9 @@ def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = Non
"artist": str or None,
"post_id": 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,
"tags": [{"name": str, "kind": str}, ...],
"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,
"post_id": None,
"post_title": None,
"description": None,
"source_url": None,
"attachment_count": 0,
"taken_at": None,
"tags": [],
"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["post_id"] = extract_post_id(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
sidecar_date = extract_date(metadata)