feat(fc2a): add /api/gallery endpoints (scroll, timeline, jump, image detail)

Thin Quart blueprint that delegates to GalleryService. Cursor-based scroll
returns images + a date_groups summary so the SPA can render year-month
headers without re-grouping client-side. Timeline returns year-month
buckets for the sidebar jump nav. Jump returns a cursor positioned at a
specific year-month so the gallery can scroll to historical periods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:09:44 -04:00
parent 509c19ce86
commit fd8cf83003
4 changed files with 183 additions and 3 deletions
+4 -2
View File
@@ -4,7 +4,7 @@ import logging
from quart import Quart
from .api import api_bp
from .api import all_blueprints
from .config import get_config
from .frontend import frontend_bp
@@ -15,7 +15,9 @@ def create_app() -> Quart:
app = Quart(__name__)
app.secret_key = cfg.secret_key
app.register_blueprint(api_bp)
for bp in all_blueprints():
app.register_blueprint(bp)
# Registered last so /api/* routes win over the SPA catch-all.
app.register_blueprint(frontend_bp)
+12 -1
View File
@@ -1,4 +1,9 @@
"""API blueprint registration."""
"""API blueprint registration.
This module is imported by the Quart app factory; it just exposes the
top-level `api_bp` for backward compatibility with FC-1's health route,
and ALL_BLUEPRINTS for the factory to register sibling blueprints.
"""
from quart import Blueprint
@@ -6,3 +11,9 @@ from . import health
api_bp = Blueprint("api", __name__, url_prefix="/api")
api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
def all_blueprints() -> list[Blueprint]:
from .gallery import gallery_bp
# FC-2a additions: tags, settings, import_admin land in later tasks
return [api_bp, gallery_bp]
+97
View File
@@ -0,0 +1,97 @@
"""Gallery API: cursor scroll, timeline, jump, image detail."""
from quart import Blueprint, jsonify, request
from ..extensions import make_engine, make_session_factory
from ..services.gallery_service import GalleryService
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
_engine = None
_Session = None
def _session_factory():
global _engine, _Session
if _engine is None:
_engine = make_engine()
_Session = make_session_factory(_engine)
return _Session
@gallery_bp.route("/scroll", methods=["GET"])
async def scroll():
cursor = request.args.get("cursor") or None
try:
limit = int(request.args.get("limit", "50"))
except ValueError:
return jsonify({"error": "limit must be an integer"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
Session = _session_factory()
async with Session() as session:
svc = GalleryService(session)
try:
page = await svc.scroll(cursor=cursor, limit=limit, tag_id=tag_id)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
{
"images": [
{
"id": i.id,
"sha256": i.sha256,
"mime": i.mime,
"width": i.width,
"height": i.height,
"created_at": i.created_at.isoformat(),
"thumbnail_url": i.thumbnail_url,
}
for i in page.images
],
"next_cursor": page.next_cursor,
"date_groups": [
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
],
}
)
@gallery_bp.route("/timeline", methods=["GET"])
async def timeline():
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
Session = _session_factory()
async with Session() as session:
svc = GalleryService(session)
buckets = await svc.timeline(tag_id=tag_id)
return jsonify([{"year": b.year, "month": b.month, "count": b.count} for b in buckets])
@gallery_bp.route("/jump", methods=["GET"])
async def jump():
try:
year = int(request.args["year"])
month = int(request.args["month"])
except (KeyError, ValueError):
return jsonify({"error": "year and month query params required"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
Session = _session_factory()
async with Session() as session:
svc = GalleryService(session)
cursor = await svc.jump_cursor(year=year, month=month, tag_id=tag_id)
return jsonify({"cursor": cursor})
@gallery_bp.route("/image/<int:image_id>", methods=["GET"])
async def image_detail(image_id: int):
Session = _session_factory()
async with Session() as session:
svc = GalleryService(session)
payload = await svc.get_image_with_tags(image_id)
if payload is None:
return jsonify({"error": "not found"}), 404
return jsonify(payload)