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
+10
View File
@@ -88,6 +88,16 @@ async def test_list_filter_propagates_artist(client, seeded_post):
assert body["items"][0]["id"] == post.id
@pytest.mark.asyncio
async def test_list_text_search_propagates(client, seeded_post):
_, _, post = seeded_post # title "Hello", description "<p>hi</p>"
hit = await client.get("/api/posts?q=hello")
assert hit.status_code == 200
assert [it["id"] for it in (await hit.get_json())["items"]] == [post.id]
miss = await client.get("/api/posts?q=zzznope")
assert (await miss.get_json())["items"] == []
@pytest.mark.asyncio
async def test_detail_200_for_known(client, seeded_post):
_, _, post = seeded_post