moving styling and views to be more consistent and for gallery view to be less cumbersome.

This commit is contained in:
Bryan Van Deusen
2026-01-19 23:41:36 -05:00
parent 41037696bf
commit 96f72718bd
16 changed files with 2447 additions and 75 deletions
+289 -16
View File
@@ -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():
"""