Files
FabledCurator/backend/app/api/posts.py
T
bvandeusen c95b760294 feat(posts): in-context anchored feed with bidirectional infinite scroll
Provenance "View post" deep-links to /posts?post_id=X, which now opens the
feed centered on that post with infinite load in BOTH directions.

Backend: PostFeedService.scroll gains a direction (older|newer); new
around(post_id) returns a window of newer + the post + older with a cursor
for each end. /api/posts accepts ?around= and ?direction=. + API tests.

Frontend: posts store gains loadAround/loadOlder/loadNewer (older appends,
newer prepends) with per-end cursors; PostsView's anchored mode scrolls to
the post, observes top + bottom sentinels, and preserves scroll position on
upward prepend so the page doesn't jump. Normal feed mode unchanged.

Closes the remaining half of the post-navigation work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:12:18 -04:00

84 lines
2.8 KiB
Python

"""FC-3e: /api/posts — cursor-paginated unified posts feed."""
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
@posts_bp.route("", methods=["GET"])
async def list_posts():
args = request.args
cursor = args.get("cursor") or None
artist_id_raw = args.get("artist_id")
platform = args.get("platform") or None
limit_raw = args.get("limit", "24")
direction = args.get("direction", "older")
around_raw = args.get("around")
try:
limit = int(limit_raw)
except ValueError:
return _bad("invalid_limit", detail="limit must be an integer")
if limit < 1 or limit > 100:
return _bad("invalid_limit", detail="limit must be between 1 and 100")
if direction not in ("older", "newer"):
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
around_id = None
if around_raw is not None:
try:
around_id = int(around_raw)
except ValueError:
return _bad("invalid_around", detail="around must be an integer post id")
artist_id = None
if artist_id_raw is not None:
try:
artist_id = int(artist_id_raw)
except ValueError:
return _bad("invalid_artist_id", detail="artist_id must be an integer")
if platform is not None and platform not in KNOWN_PLATFORMS:
return _bad(
"unknown_platform",
detail=f"platform must be one of {sorted(KNOWN_PLATFORMS)}",
)
async with get_session() as session:
svc = PostFeedService(session)
if around_id is not None:
result = await svc.around(
post_id=around_id, artist_id=artist_id,
platform=platform, limit=limit,
)
if result is None:
return _bad("not_found", status=404, detail=f"post id={around_id}")
return jsonify(result)
try:
page = await svc.scroll(
cursor=cursor, artist_id=artist_id,
platform=platform, limit=limit, direction=direction,
)
except ValueError as exc:
# Service raises ValueError for malformed cursors only;
# limit bounds are validated above.
return _bad("invalid_cursor", detail=str(exc))
return jsonify(page)
@posts_bp.route("/<int:post_id>", methods=["GET"])
async def get_post(post_id: int):
async with get_session() as session:
item = await PostFeedService(session).get_post(post_id)
if item is None:
return _bad("not_found", status=404, detail=f"post id={post_id}")
return jsonify(item)