2c544ad5af
The Browse tab nav scrolled away (operator didn't know it existed) and Posts had no search. Roll the tab strip + a shared search field into one sticky block pinned under the 64px TopNav. - Posts gains server-side text search: PostFeedService.scroll()/around() + /api/posts accept q (ILIKE over post_title OR description), applied INSIDE the artist/platform WHERE so search stays scoped to the active filter. Scope shown as clearable chips next to the search field. - Artists/Tags search consolidates into the sticky bar: their inner search boxes are removed; they react to route.query.q (q is deep- linkable, e.g. /browse?tab=posts&q=foo). Platform/kind filters stay. - Posts empty state now distinguishes 'no matches' from 'no posts yet'. Tests: posts q-search matches title|description and stays artist-scoped (service); q passthrough (api). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85 lines
2.9 KiB
Python
85 lines
2.9 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
|
|
q = (args.get("q") or "").strip() 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, q=q, 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, q=q, 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)
|