Gallery
+ {% if active_tag %} ++ {{ year_month_label }} + {{ images|length }} images +
+No images found{% if active_tag %} for tag "{{ active_tag }}"{% endif %}.
+diff --git a/app/__init__.py b/app/__init__.py index e69b946..2e3152f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,8 @@ # app/__init__.py +import click from flask import Flask +from flask.cli import with_appcontext from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from werkzeug.middleware.proxy_fix import ProxyFix @@ -11,6 +13,18 @@ import sqlite3 db = SQLAlchemy() migrate = Migrate() + +# ============================================================================= +# CLI Commands +# ============================================================================= + +@click.command("version-check") +@with_appcontext +def version_check_command(): + """Check app version and run data migrations if needed (e.g., phash recomputation).""" + from app.utils.version_migration import check_and_run_migrations + check_and_run_migrations() + @event.listens_for(Engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): # Only apply to SQLite connections @@ -38,4 +52,7 @@ def create_app(config_class='config.Config'): def datetimeformat(value, format="%Y-%m-%d"): return value.strftime(format) if value else "" + # Register CLI commands + app.cli.add_command(version_check_command) + return app diff --git a/app/main.py b/app/main.py index ce83867..3a48c0b 100644 --- a/app/main.py +++ b/app/main.py @@ -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(): """ diff --git a/app/models.py b/app/models.py index 2e28ce6..03ea892 100644 --- a/app/models.py +++ b/app/models.py @@ -23,7 +23,7 @@ class ImageRecord(db.Model): filepath = db.Column(db.String(512), nullable=False) thumb_path = db.Column(db.String(512)) hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256 - perceptual_hash = db.Column(db.String(16), nullable=True) # hex of 64-bit hash + perceptual_hash = db.Column(db.String(64), nullable=True) # hex of 256-bit hash file_size = db.Column(db.Integer, nullable=True) width = db.Column(db.Integer, nullable=True) height = db.Column(db.Integer, nullable=True) @@ -64,3 +64,15 @@ class ArchiveRecord(db.Model): artist = db.Column(db.String(255), nullable=True) tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True) tag = db.relationship("Tag") + + +class AppSettings(db.Model): + """ + Key-value store for application settings and version tracking. + Used to persist configuration and trigger data migrations on version changes. + """ + __tablename__ = "app_settings" + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(64), unique=True, nullable=False) + value = db.Column(db.Text, nullable=True) + updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) diff --git a/app/static/js/gallery-infinite.js b/app/static/js/gallery-infinite.js new file mode 100644 index 0000000..d7cd46d --- /dev/null +++ b/app/static/js/gallery-infinite.js @@ -0,0 +1,475 @@ +// /app/static/js/gallery-infinite.js +// Infinite scroll gallery with year-month date dividers and timeline navigation + +(function() { + 'use strict'; + + // Configuration + const CONFIG = { + SCROLL_THRESHOLD: 1200, // px from bottom to trigger load + BATCH_SIZE: 30, // images per API request + DEBOUNCE_SCROLL: 100, // ms debounce for scroll handler + DEBOUNCE_RESIZE: 200 // ms debounce for resize handler + }; + + // State + const state = { + isLoading: false, + hasMore: true, + cursor: null, + activeTag: null, + loadedSections: new Set(), // year_month keys of loaded sections + timelineData: [], + currentVisibleSection: null, + isJumping: false // true during jump navigation + }; + + // DOM references + const dom = { + container: null, + loading: null, + end: null, + timeline: null, + timelineTrack: null + }; + + // Initialize + function init() { + dom.container = document.getElementById('galleryInfinite'); + dom.loading = document.getElementById('galleryLoading'); + dom.end = document.getElementById('galleryEnd'); + dom.timeline = document.getElementById('timelineSidebar'); + dom.timelineTrack = document.getElementById('timelineTrack'); + + if (!dom.container) return; + + // Parse initial state from window.galleryState (set by template) + if (window.galleryState) { + state.cursor = window.galleryState.cursor; + state.hasMore = window.galleryState.hasMore; + state.activeTag = window.galleryState.activeTag; + } + + // Track initially loaded sections + parseInitialSections(); + + // Load timeline data + loadTimeline(); + + // Setup event listeners + setupScrollListener(); + setupIntersectionObserver(); + + // Check if we need to load more on init (short content) + setTimeout(checkScrollPosition, 100); + } + + // Parse initially rendered sections + function parseInitialSections() { + const sections = dom.container.querySelectorAll('.date-section'); + sections.forEach(section => { + const yearMonth = section.dataset.yearMonth; + if (yearMonth) { + state.loadedSections.add(yearMonth); + } + }); + } + + // Fetch more images + async function loadMore() { + if (state.isLoading || !state.hasMore) return; + + state.isLoading = true; + dom.loading.style.display = 'flex'; + + try { + const params = new URLSearchParams({ + limit: CONFIG.BATCH_SIZE + }); + + if (state.cursor) { + params.set('cursor', state.cursor); + } + if (state.activeTag) { + params.set('tag', state.activeTag); + } + + const response = await fetch(`/api/gallery/scroll?${params}`); + const data = await response.json(); + + if (data.images && data.images.length > 0) { + appendImages(data.images); + state.cursor = data.next_cursor; + state.hasMore = data.has_more; + + // Notify modal system + dispatchGalleryUpdate(); + } + + if (!data.has_more) { + state.hasMore = false; + dom.end.style.display = 'block'; + } + } catch (err) { + console.error('Failed to load more images:', err); + } finally { + state.isLoading = false; + dom.loading.style.display = 'none'; + } + } + + // Append images to the gallery + function appendImages(images) { + // Group images by year_month + const groups = new Map(); + + images.forEach(img => { + const key = img.year_month; + if (!groups.has(key)) { + groups.set(key, { + key: key, + label: img.year_month_label, + images: [] + }); + } + groups.get(key).images.push(img); + }); + + // Append to existing sections or create new ones + groups.forEach((group, key) => { + let section = dom.container.querySelector(`.date-section[data-year-month="${key}"]`); + + if (section) { + // Append to existing section + const grid = section.querySelector('.gallery-grid'); + group.images.forEach(img => { + grid.appendChild(createImageElement(img)); + }); + // Update count + updateSectionCount(section); + } else { + // Create new section + section = createDateSection(group.key, group.label, group.images); + dom.container.appendChild(section); + } + + state.loadedSections.add(key); + }); + + // Update timeline highlights + updateTimelineLoaded(); + } + + // Create a date section element + function createDateSection(yearMonth, label, images) { + const section = document.createElement('section'); + section.className = 'date-section'; + section.dataset.yearMonth = yearMonth; + + const divider = document.createElement('h2'); + divider.className = 'date-divider'; + divider.id = `section-${yearMonth}`; + divider.innerHTML = ` + ${label} + ${images.length} images + `; + + const grid = document.createElement('div'); + grid.className = 'gallery-grid'; + + images.forEach(img => { + grid.appendChild(createImageElement(img)); + }); + + section.appendChild(divider); + section.appendChild(grid); + + return section; + } + + // Create a single image element + function createImageElement(img) { + const item = document.createElement('div'); + item.className = 'gallery-item img-clickable'; + item.dataset.id = img.id; + item.dataset.full = img.full_url; + item.dataset.type = img.is_video ? 'video' : 'image'; + item.dataset.filename = img.filename; + + // Build tag overlay HTML + let tagsHtml = ''; + if (img.tags && img.tags.length > 0) { + const tagChips = img.tags.map(t => { + const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name; + const prefix = t.kind === 'user' ? '#' : ''; + return `${prefix}${displayName}`; + }).join(''); + tagsHtml = `
`; + } + + // Play overlay for videos + const playOverlay = img.is_video ? '' : ''; + + item.innerHTML = ` + + ${tagsHtml} + ${playOverlay} + ${img.date} + `; + + return item; + } + + // Update section image count + function updateSectionCount(section) { + const grid = section.querySelector('.gallery-grid'); + const count = grid.querySelectorAll('.gallery-item').length; + const countEl = section.querySelector('.date-divider-count'); + if (countEl) { + countEl.textContent = `${count} images`; + } + } + + // Scroll handling + function setupScrollListener() { + let scrollTimeout = null; + + window.addEventListener('scroll', () => { + if (scrollTimeout) return; + scrollTimeout = setTimeout(() => { + scrollTimeout = null; + checkScrollPosition(); + }, CONFIG.DEBOUNCE_SCROLL); + }, { passive: true }); + } + + function checkScrollPosition() { + if (state.isLoading || !state.hasMore || state.isJumping) return; + + const scrollBottom = window.innerHeight + window.scrollY; + const docHeight = document.documentElement.scrollHeight; + + if (docHeight - scrollBottom < CONFIG.SCROLL_THRESHOLD) { + loadMore(); + } + } + + // Intersection observer for tracking visible section + function setupIntersectionObserver() { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const yearMonth = entry.target.dataset.yearMonth; + if (yearMonth && yearMonth !== state.currentVisibleSection) { + state.currentVisibleSection = yearMonth; + updateTimelineActive(yearMonth); + } + } + }); + }, { + rootMargin: '-100px 0px -60% 0px', + threshold: 0 + }); + + // Observe all date sections + const observeSections = () => { + dom.container.querySelectorAll('.date-section').forEach(section => { + observer.observe(section); + }); + }; + + observeSections(); + + // Re-observe when new sections are added + const mutationObserver = new MutationObserver(() => { + observeSections(); + }); + mutationObserver.observe(dom.container, { childList: true }); + } + + // Timeline functions + async function loadTimeline() { + try { + const params = state.activeTag ? `?tag=${encodeURIComponent(state.activeTag)}` : ''; + const response = await fetch(`/api/gallery/timeline${params}`); + const data = await response.json(); + + if (data.groups) { + state.timelineData = data.groups; + renderTimeline(); + } + } catch (err) { + console.error('Failed to load timeline:', err); + dom.timelineTrack.innerHTML = 'No images found{% if active_tag %} for tag "{{ active_tag }}"{% endif %}.
+