moving styling and views to be more consistent and for gallery view to be less cumbersome.
This commit is contained in:
+289
-16
@@ -2,8 +2,10 @@
|
||||
|
||||
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
|
||||
|
||||
from sqlalchemy import desc, func, text
|
||||
from sqlalchemy import desc, func, text, extract
|
||||
from sqlalchemy.orm import joinedload
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags
|
||||
from app import db
|
||||
@@ -78,35 +80,47 @@ def random_images_api():
|
||||
@main.route('/gallery')
|
||||
def gallery():
|
||||
"""
|
||||
Gallery with pagination and optional tag filter (?tag=artist:NAME or any tag name).
|
||||
Keeps your pagination shape so modal-pagination.js continues to work.
|
||||
Gallery with infinite scroll and year-month date dividers.
|
||||
Server renders initial batch, then JS takes over for infinite scroll.
|
||||
Optional tag filter via ?tag=artist:NAME or any tag name.
|
||||
"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 35, type=int)
|
||||
initial_limit = 30
|
||||
tag_name = request.args.get('tag')
|
||||
|
||||
# Base query + eager-load tags (cheap and future-proofs tag chips)
|
||||
# Base query + eager-load tags
|
||||
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
||||
|
||||
# Optional tag filter
|
||||
if tag_name:
|
||||
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||
|
||||
images = q.order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) \
|
||||
.paginate(page=page, per_page=per_page)
|
||||
# Fetch initial batch ordered by date descending
|
||||
images = q.order_by(
|
||||
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
|
||||
).limit(initial_limit + 1).all()
|
||||
|
||||
# Preserve next/prev URLs (carry tag= so modal pagination fetches stay filtered)
|
||||
next_url = url_for('main.gallery', page=images.next_num, per_page=per_page, tag=tag_name) if images.has_next else ''
|
||||
prev_url = url_for('main.gallery', page=images.prev_num, per_page=per_page, tag=tag_name) if images.has_prev else ''
|
||||
# Check if there are more images
|
||||
has_more = len(images) > initial_limit
|
||||
if has_more:
|
||||
images = images[:initial_limit]
|
||||
|
||||
# Group by year-month for initial render
|
||||
grouped_images = _group_images_by_month(images)
|
||||
|
||||
# Compute initial cursor for JS to continue from
|
||||
initial_cursor = None
|
||||
if images and has_more:
|
||||
last_img = images[-1]
|
||||
last_dt = _get_image_date(last_img)
|
||||
if last_dt:
|
||||
initial_cursor = last_dt.isoformat()
|
||||
|
||||
return render_template(
|
||||
'gallery.html',
|
||||
images=images,
|
||||
grouped_images=grouped_images,
|
||||
active_tag=tag_name,
|
||||
has_next=images.has_next,
|
||||
next_page_url=next_url,
|
||||
has_prev=images.has_prev,
|
||||
prev_page_url=prev_url
|
||||
initial_cursor=initial_cursor,
|
||||
has_more=has_more
|
||||
)
|
||||
|
||||
|
||||
@@ -115,6 +129,265 @@ def serve_image(filename):
|
||||
return send_from_directory('/images', filename)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Gallery Infinite Scroll API
|
||||
# ----------------------------
|
||||
|
||||
def _get_image_date(img):
|
||||
"""Get the effective date for an image (taken_at or imported_at)."""
|
||||
return img.taken_at or img.imported_at
|
||||
|
||||
|
||||
def _format_year_month(dt):
|
||||
"""Format a datetime as year-month key and label."""
|
||||
if not dt:
|
||||
return 'unknown', 'Unknown Date'
|
||||
return dt.strftime('%Y-%m'), dt.strftime('%B %Y')
|
||||
|
||||
|
||||
def _group_images_by_month(images):
|
||||
"""
|
||||
Group images by year-month, preserving order.
|
||||
Returns list of (group_info, images) tuples.
|
||||
"""
|
||||
groups = OrderedDict()
|
||||
|
||||
for img in images:
|
||||
dt = _get_image_date(img)
|
||||
key, label = _format_year_month(dt)
|
||||
|
||||
if key not in groups:
|
||||
groups[key] = {'key': key, 'label': label, 'images': []}
|
||||
|
||||
groups[key]['images'].append(img)
|
||||
|
||||
return [(info['key'], info['label'], info['images']) for info in groups.values()]
|
||||
|
||||
|
||||
def _serialize_image(img):
|
||||
"""Serialize an ImageRecord for JSON response."""
|
||||
is_video = img.filename.lower().endswith(('.mp4', '.mov'))
|
||||
thumb_path = (img.thumb_path or img.filepath).replace('/images/', '')
|
||||
full_path = img.filepath.replace('/images/', '')
|
||||
dt = _get_image_date(img)
|
||||
key, label = _format_year_month(dt)
|
||||
|
||||
return {
|
||||
'id': img.id,
|
||||
'filename': img.filename,
|
||||
'thumb_url': url_for('main.serve_image', filename=thumb_path),
|
||||
'full_url': url_for('main.serve_image', filename=full_path),
|
||||
'is_video': is_video,
|
||||
'date': dt.strftime('%Y-%m-%d') if dt else '',
|
||||
'year_month': key,
|
||||
'year_month_label': label,
|
||||
'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags if t.kind != 'archive']
|
||||
}
|
||||
|
||||
|
||||
@main.route('/api/gallery/scroll')
|
||||
def gallery_scroll():
|
||||
"""
|
||||
Cursor-based infinite scroll endpoint with date grouping.
|
||||
|
||||
Query params:
|
||||
- cursor: ISO datetime string for pagination (fetch images older than this)
|
||||
- limit: number of images per batch (default 30)
|
||||
- tag: optional tag filter
|
||||
|
||||
Returns:
|
||||
- images: list of image objects with year_month grouping info
|
||||
- next_cursor: datetime string for next batch (null if no more)
|
||||
- date_groups: summary of year-month groups in this batch
|
||||
- has_more: boolean indicating if more images exist
|
||||
"""
|
||||
cursor_str = request.args.get('cursor')
|
||||
limit = request.args.get('limit', 30, type=int)
|
||||
tag_name = request.args.get('tag')
|
||||
|
||||
# Build base query
|
||||
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
||||
|
||||
# Optional tag filter
|
||||
if tag_name:
|
||||
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||
|
||||
# Apply cursor filter (fetch images older than cursor)
|
||||
if cursor_str:
|
||||
try:
|
||||
cursor_dt = datetime.fromisoformat(cursor_str.replace('Z', '+00:00'))
|
||||
q = q.filter(
|
||||
func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at) < cursor_dt
|
||||
)
|
||||
except ValueError:
|
||||
pass # Invalid cursor, ignore
|
||||
|
||||
# Order by date descending and fetch with limit + 1 to check has_more
|
||||
images = q.order_by(
|
||||
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
|
||||
).limit(limit + 1).all()
|
||||
|
||||
# Check if there are more images
|
||||
has_more = len(images) > limit
|
||||
if has_more:
|
||||
images = images[:limit]
|
||||
|
||||
# Serialize images
|
||||
serialized = [_serialize_image(img) for img in images]
|
||||
|
||||
# Build date_groups summary
|
||||
groups_seen = OrderedDict()
|
||||
for s in serialized:
|
||||
key = s['year_month']
|
||||
if key not in groups_seen:
|
||||
groups_seen[key] = {'key': key, 'label': s['year_month_label'], 'count': 0}
|
||||
groups_seen[key]['count'] += 1
|
||||
|
||||
# Compute next cursor
|
||||
next_cursor = None
|
||||
if images and has_more:
|
||||
last_img = images[-1]
|
||||
last_dt = _get_image_date(last_img)
|
||||
if last_dt:
|
||||
next_cursor = last_dt.isoformat()
|
||||
|
||||
return jsonify(
|
||||
images=serialized,
|
||||
next_cursor=next_cursor,
|
||||
has_more=has_more,
|
||||
date_groups=list(groups_seen.values())
|
||||
)
|
||||
|
||||
|
||||
@main.route('/api/gallery/timeline')
|
||||
def gallery_timeline():
|
||||
"""
|
||||
Returns all year-month groups with counts for timeline navigation.
|
||||
|
||||
Query params:
|
||||
- tag: optional tag filter (same as gallery)
|
||||
|
||||
Returns list of {key, label, count, year} ordered newest to oldest.
|
||||
"""
|
||||
tag_name = request.args.get('tag')
|
||||
|
||||
# Use SQL to aggregate by year-month
|
||||
date_expr = func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)
|
||||
|
||||
q = db.session.query(
|
||||
extract('year', date_expr).label('year'),
|
||||
extract('month', date_expr).label('month'),
|
||||
func.count(ImageRecord.id).label('count')
|
||||
)
|
||||
|
||||
if tag_name:
|
||||
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||
|
||||
q = q.group_by(
|
||||
extract('year', date_expr),
|
||||
extract('month', date_expr)
|
||||
).order_by(
|
||||
extract('year', date_expr).desc(),
|
||||
extract('month', date_expr).desc()
|
||||
)
|
||||
|
||||
results = q.all()
|
||||
|
||||
# Format results
|
||||
groups = []
|
||||
for row in results:
|
||||
if row.year and row.month:
|
||||
year = int(row.year)
|
||||
month = int(row.month)
|
||||
key = f"{year:04d}-{month:02d}"
|
||||
# Create a date object to format the month name
|
||||
dt = datetime(year, month, 1)
|
||||
label = dt.strftime('%B %Y')
|
||||
short_label = dt.strftime('%b') # Short month name for sidebar
|
||||
groups.append({
|
||||
'key': key,
|
||||
'label': label,
|
||||
'short_label': short_label,
|
||||
'year': year,
|
||||
'month': month,
|
||||
'count': row.count
|
||||
})
|
||||
|
||||
return jsonify(groups=groups)
|
||||
|
||||
|
||||
@main.route('/api/gallery/jump')
|
||||
def gallery_jump():
|
||||
"""
|
||||
Fetch images starting from a specific year-month for timeline jump navigation.
|
||||
|
||||
Query params:
|
||||
- year_month: target year-month (e.g., '2024-03')
|
||||
- limit: number of images (default 30)
|
||||
- tag: optional tag filter
|
||||
|
||||
Returns same format as /api/gallery/scroll but starting from the target month.
|
||||
"""
|
||||
year_month = request.args.get('year_month', '')
|
||||
limit = request.args.get('limit', 30, type=int)
|
||||
tag_name = request.args.get('tag')
|
||||
|
||||
if not year_month:
|
||||
return jsonify(error='year_month is required'), 400
|
||||
|
||||
try:
|
||||
# Parse year-month to get start of that month
|
||||
target_dt = datetime.strptime(year_month + '-01', '%Y-%m-%d')
|
||||
# Get end of month (start of next month)
|
||||
if target_dt.month == 12:
|
||||
next_month = datetime(target_dt.year + 1, 1, 1)
|
||||
else:
|
||||
next_month = datetime(target_dt.year, target_dt.month + 1, 1)
|
||||
except ValueError:
|
||||
return jsonify(error='Invalid year_month format'), 400
|
||||
|
||||
# Build query for images in that month and earlier
|
||||
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
||||
|
||||
if tag_name:
|
||||
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||
|
||||
# Get images from start of target month onwards (but ordered descending)
|
||||
date_expr = func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)
|
||||
q = q.filter(date_expr < next_month)
|
||||
|
||||
images = q.order_by(desc(date_expr)).limit(limit + 1).all()
|
||||
|
||||
has_more = len(images) > limit
|
||||
if has_more:
|
||||
images = images[:limit]
|
||||
|
||||
serialized = [_serialize_image(img) for img in images]
|
||||
|
||||
# Build date_groups summary
|
||||
groups_seen = OrderedDict()
|
||||
for s in serialized:
|
||||
key = s['year_month']
|
||||
if key not in groups_seen:
|
||||
groups_seen[key] = {'key': key, 'label': s['year_month_label'], 'count': 0}
|
||||
groups_seen[key]['count'] += 1
|
||||
|
||||
next_cursor = None
|
||||
if images and has_more:
|
||||
last_img = images[-1]
|
||||
last_dt = _get_image_date(last_img)
|
||||
if last_dt:
|
||||
next_cursor = last_dt.isoformat()
|
||||
|
||||
return jsonify(
|
||||
images=serialized,
|
||||
next_cursor=next_cursor,
|
||||
has_more=has_more,
|
||||
date_groups=list(groups_seen.values()),
|
||||
target_year_month=year_month
|
||||
)
|
||||
|
||||
|
||||
@main.route('/tags')
|
||||
def tag_list():
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user