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
co-authored by Claude Opus 4.8
parent 90c68f8b2a
commit 2c544ad5af
9 changed files with 237 additions and 44 deletions
+9 -2
View File
@@ -59,7 +59,8 @@
</div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<p>No posts yet. Subscribe to a source on the
<p v-if="hasActiveFilter">No posts match your search or filters.</p>
<p v-else>No posts yet. Subscribe to a source on the
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
@@ -93,10 +94,14 @@ const artistFilter = computed(() => {
return raw == null ? null : Number(raw)
})
const platformFilter = computed(() => route.query.platform || null)
const searchFilter = computed(() => route.query.q || null)
const postIdFilter = computed(() => {
const raw = route.query.post_id
return raw == null ? null : Number(raw)
})
const hasActiveFilter = computed(() =>
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
)
// --- normal feed (downward infinite scroll) ---
const sentinel = ref(null)
@@ -106,6 +111,7 @@ async function reload() {
await store.loadInitial({
artist_id: artistFilter.value,
platform: platformFilter.value,
q: searchFilter.value,
})
}
function onFilters({ artist_id, platform }) {
@@ -169,6 +175,7 @@ async function loadAroundAndAnchor() {
await store.loadAround(postIdFilter.value, {
artist_id: artistFilter.value,
platform: platformFilter.value,
q: searchFilter.value,
})
await nextTick()
const el = document.getElementById(`fc-post-${store.anchorId}`)
@@ -188,7 +195,7 @@ async function activate() {
}
watch(postIdFilter, activate)
watch(() => [artistFilter.value, platformFilter.value], async () => {
watch(() => [artistFilter.value, platformFilter.value, searchFilter.value], async () => {
if (postIdFilter.value != null) return
await reload()
await nextTick()