feat(browse): sticky tabs + per-tab search bar (server-side, scope-aware)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m10s

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>
This commit is contained in:
2026-06-12 00:04:06 -04:00
parent 90c68f8b2a
commit 2c544ad5af
9 changed files with 237 additions and 44 deletions
+3 -2
View File
@@ -17,6 +17,7 @@ async def list_posts():
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")
@@ -56,7 +57,7 @@ async def list_posts():
if around_id is not None:
result = await svc.around(
post_id=around_id, artist_id=artist_id,
platform=platform, limit=limit,
platform=platform, q=q, limit=limit,
)
if result is None:
return _bad("not_found", status=404, detail=f"post id={around_id}")
@@ -64,7 +65,7 @@ async def list_posts():
try:
page = await svc.scroll(
cursor=cursor, artist_id=artist_id,
platform=platform, limit=limit, direction=direction,
platform=platform, q=q, limit=limit, direction=direction,
)
except ValueError as exc:
# Service raises ValueError for malformed cursors only;
+16 -3
View File
@@ -45,6 +45,7 @@ class PostFeedService:
cursor: str | None = None,
artist_id: int | None = None,
platform: str | None = None,
q: str | None = None,
limit: int = 24,
direction: str = "older",
) -> dict:
@@ -52,7 +53,12 @@ class PostFeedService:
time (default, infinite-scroll down); direction='newer' walks forward
(scroll up in an anchored view). Items are always returned in feed
(descending) order; `next_cursor` points to the far edge in the
requested direction (null when exhausted)."""
requested direction (null when exhausted).
`q` is a free-text filter (ILIKE substring over post_title OR
description) applied INSIDE the artist/platform scope, so a search
from the Browse bar stays within whatever artist is filtered in
view (operator-asked 2026-06-11)."""
if limit < 1 or limit > 100:
raise ValueError("limit must be between 1 and 100")
if direction not in ("older", "newer"):
@@ -74,6 +80,12 @@ class PostFeedService:
stmt = stmt.where(Post.artist_id == artist_id)
if platform is not None:
stmt = stmt.where(Source.platform == platform)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(
Post.post_title.ilike(like),
Post.description.ilike(like),
))
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
if direction == "older":
@@ -124,6 +136,7 @@ class PostFeedService:
post_id: int,
artist_id: int | None = None,
platform: str | None = None,
q: str | None = None,
limit: int = 12,
) -> dict | None:
"""A window centered on `post_id`: up to `limit` newer posts + the
@@ -143,11 +156,11 @@ class PostFeedService:
older = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="older",
q=q, limit=limit, direction="older",
)
newer = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="newer",
q=q, limit=limit, direction="newer",
)
thumbs_map = await self._thumbnails_for([anchor_post.id])
atts_map = await self._attachments_for([anchor_post.id])