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])
+5 -1
View File
@@ -11,7 +11,7 @@ export const usePostsStore = defineStore('posts', () => {
const cursor = ref(null)
const { loading, error, run } = useAsyncAction()
const done = ref(false)
const filters = ref({ artist_id: null, platform: null })
const filters = ref({ artist_id: null, platform: null, q: null })
// In-context (anchored) view: bidirectional cursors so the feed can load
// newer posts on scroll-up and older posts on scroll-down around a post.
@@ -32,6 +32,7 @@ export const usePostsStore = defineStore('posts', () => {
if (cursor.value) q.cursor = cursor.value
if (filters.value.artist_id != null) q.artist_id = filters.value.artist_id
if (filters.value.platform) q.platform = filters.value.platform
if (filters.value.q) q.q = filters.value.q
return q
}
@@ -47,6 +48,7 @@ export const usePostsStore = defineStore('posts', () => {
filters.value = {
artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null,
q: newFilters?.q ?? null,
}
_reset()
await loadMore()
@@ -78,6 +80,7 @@ export const usePostsStore = defineStore('posts', () => {
const p = { ...extra }
if (filters.value.artist_id != null) p.artist_id = filters.value.artist_id
if (filters.value.platform) p.platform = filters.value.platform
if (filters.value.q) p.q = filters.value.q
return p
}
@@ -93,6 +96,7 @@ export const usePostsStore = defineStore('posts', () => {
filters.value = {
artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null,
q: newFilters?.q ?? null,
}
loading.value = true
error.value = null
+10 -13
View File
@@ -1,12 +1,9 @@
<template>
<v-container fluid class="pt-2 pb-6">
<!-- Search lives in BrowseView's sticky bar (shared across tabs); this view
reacts to route.query.q. The platform filter stays here. -->
<div class="fc-artists__controls">
<v-text-field
v-model="search" density="compact" variant="outlined"
prepend-inner-icon="mdi-magnify" hide-details
placeholder="Search artists" clearable style="max-width: 320px;"
/>
<v-select
v-model="platformModel"
:items="platformItems"
@@ -34,26 +31,25 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useArtistDirectoryStore } from '../stores/artistDirectory.js'
import { usePlatformsStore } from '../stores/platforms.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import ArtistCard from '../components/discovery/ArtistCard.vue'
const route = useRoute()
const store = useArtistDirectoryStore()
const platformsStore = usePlatformsStore()
const search = ref('')
const platformModel = ref(null)
const sentinelEl = ref(null)
const platformItems = ref([])
let debounce = null
watch(search, (v) => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => store.setQuery(v || ''), 300)
})
// Search term comes from BrowseView's shared sticky bar via route.query.q.
const searchTerm = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
watch(searchTerm, (v) => store.setQuery(v || ''))
watch(platformModel, (v) => store.setPlatform(v || null))
useInfiniteScroll(sentinelEl, () => {
@@ -65,7 +61,8 @@ onMounted(async () => {
platformItems.value = (platformsStore.platforms || []).map(p => ({
title: p.key, value: p.key,
}))
store.reset()
// Apply any deep-linked q (and load). setQuery resets + fetches.
store.setQuery(searchTerm.value || '')
})
</script>
+121 -4
View File
@@ -1,17 +1,54 @@
<template>
<div class="fc-browse">
<v-container fluid class="pt-2 pb-0">
<v-tabs v-model="tab" density="compact">
<!-- Sticky browse chrome: the tab strip + a per-tab search bar, both pinned
directly under the 64px TopNav (AppShell). Operator-asked 2026-06-11:
the tab nav scrolled away (they didn't know it existed), and there was
no search on Posts. Tabs + search live in ONE sticky block so the axis
switcher and the search stay reachable no matter how far you scroll.
Background uses the theme surface token so content scrolls cleanly
under it (matches SettingsView's sticky tabs). -->
<div class="fc-browse__head">
<v-container fluid class="py-0">
<v-tabs v-model="tab" density="compact" color="accent">
<v-tab value="posts">Posts</v-tab>
<v-tab value="artists">Artists</v-tab>
<v-tab value="tags">Tags</v-tab>
</v-tabs>
<div class="fc-browse__search">
<v-text-field
v-model="searchInput"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
:placeholder="searchPlaceholder"
:aria-label="searchPlaceholder"
class="fc-browse__search-field"
/>
<!-- Posts search stays scoped to the active artist/platform filter
(operator-asked: 'if an artist is currently filtered in view').
The chips surface that scope next to the search even after the
PostsFilterBar scrolls off, and clear it to broaden. -->
<template v-if="tab === 'posts'">
<v-chip
v-if="artistId != null" closable size="small"
variant="tonal" color="accent" prepend-icon="mdi-account"
@click:close="clearFilter('artist_id')"
>{{ artistChipLabel }}</v-chip>
<v-chip
v-if="platform" closable size="small"
variant="tonal" color="accent" prepend-icon="mdi-web"
@click:close="clearFilter('platform')"
>{{ platform }}</v-chip>
</template>
</div>
</v-container>
</div>
<!-- Each tab's view is self-contained (its own container + state). Only the
active one is mounted; switching tabs swaps it. The Posts tab still
reads its deep-link (post_id/artist_id/platform) from route.query, so
/browse?tab=posts&post_id=N works exactly like the old /posts deep link.
All three tabs read the shared `q` search term from route.query.
(FC nav consolidation, operator-asked 2026-06-09: Posts/Artists/Tags are
the three "browse the library by an axis" surfaces.) -->
<component :is="activeComponent" :key="tab" />
@@ -19,17 +56,24 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePostsStore } from '../stores/posts.js'
import ArtistsView from './ArtistsView.vue'
import PostsView from './PostsView.vue'
import TagsView from './TagsView.vue'
const TABS = { posts: PostsView, artists: ArtistsView, tags: TagsView }
const PLACEHOLDERS = {
posts: 'Search posts (title, description)',
artists: 'Search artists',
tags: 'Search tags',
}
const route = useRoute()
const router = useRouter()
const postsStore = usePostsStore()
const tab = computed({
get() {
@@ -38,10 +82,83 @@ const tab = computed({
},
set(value) {
// Switching tabs starts a clean tab — a posts deep link (post_id, etc.)
// belongs only to the Posts tab and shouldn't bleed into Artists/Tags.
// and the search term belong only to the tab they were typed in and
// shouldn't bleed into Artists/Tags.
router.replace({ name: 'browse', query: { tab: value } })
},
})
const activeComponent = computed(() => TABS[tab.value])
const searchPlaceholder = computed(() => PLACEHOLDERS[tab.value])
// --- search term <-> route.query.q (debounced; deep-linkable) ---------------
// One sticky field drives every tab. The term lives in the URL (q=) so it's
// shareable and survives reload; each tab view watches route.query.q and
// re-queries the server (so the search surfaces content not yet scrolled into
// view, not just a client-side filter of loaded rows).
const searchInput = ref(typeof route.query.q === 'string' ? route.query.q : '')
let debounceTimer = null
let syncingFromRoute = false
watch(searchInput, (v) => {
if (syncingFromRoute) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
const next = (v || '').trim()
const query = { ...route.query }
if (next) query.q = next
else delete query.q
router.replace({ name: 'browse', query })
}, 300)
})
// Keep the field in sync when q changes from elsewhere (tab switch clears it,
// browser back/forward, a deep link). Guarded so it doesn't re-trigger a route
// write.
watch(() => route.query.q, (q) => {
const val = typeof q === 'string' ? q : ''
if (val === searchInput.value) return
syncingFromRoute = true
searchInput.value = val
nextTick(() => { syncingFromRoute = false })
})
// --- active-scope chips (Posts) ---------------------------------------------
const artistId = computed(() => {
const raw = route.query.artist_id
return raw == null ? null : Number(raw)
})
const platform = computed(() => route.query.platform || null)
// Resolve the artist's name from whatever the posts feed has loaded for this
// scope; fall back to a generic label when the search returns nothing.
const artistChipLabel = computed(() => {
const hit = postsStore.items.find((p) => p.artist?.id === artistId.value)
return hit?.artist?.name || 'Artist'
})
function clearFilter(key) {
const query = { ...route.query }
delete query[key]
router.replace({ name: 'browse', query })
}
</script>
<style scoped>
.fc-browse__head {
position: sticky;
top: 64px; /* directly under AppShell's 64px sticky TopNav */
z-index: 4;
background: rgb(var(--v-theme-surface));
}
.fc-browse__search {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 10px 0 12px;
}
.fc-browse__search-field {
max-width: 360px;
flex: 1 1 240px;
}
</style>
+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()
+10 -14
View File
@@ -1,12 +1,9 @@
<template>
<v-container fluid class="pt-2 pb-6">
<!-- Search lives in BrowseView's sticky bar (shared across tabs); this view
reacts to route.query.q. The kind filter stays here. -->
<div class="fc-tags__controls">
<v-text-field
v-model="search" density="compact" variant="outlined"
prepend-inner-icon="mdi-magnify" hide-details
placeholder="Search tags" clearable style="max-width: 320px;"
/>
<v-chip-group
v-model="kind" selected-class="text-accent" :mandatory="false"
>
@@ -103,8 +100,8 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
import { useAdminStore } from '../stores/admin.js'
import { useApi } from '../composables/useApi.js'
@@ -122,9 +119,9 @@ import FandomSetDialog from '../components/modal/FandomSetDialog.vue'
// a tag. 'meta' + 'rating' retired by operator 2026-05-26 (alembic 0023).
const KINDS = ['character', 'fandom', 'general', 'series']
const store = useTagDirectoryStore()
const route = useRoute()
const router = useRouter()
const search = ref('')
const kind = ref(null)
const sentinelEl = ref(null)
const pendingMerge = ref(null)
@@ -140,17 +137,16 @@ async function confirmMerge() {
if (c) await store.merge(c.sourceId, c.target.id)
}
let debounce = null
watch(search, (v) => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => store.setQuery(v || ''), 300)
})
// Search term comes from BrowseView's shared sticky bar via route.query.q.
const searchTerm = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
watch(searchTerm, (v) => store.setQuery(v || ''))
watch(kind, (v) => store.setKind(v || null))
useInfiniteScroll(sentinelEl, () => {
if (store.hasMore && !store.loading) store.loadMore()
})
onMounted(() => store.reset())
// Apply any deep-linked q (and load). setQuery resets + fetches.
onMounted(() => store.setQuery(searchTerm.value || ''))
function openTag(tagId) {
router.push({ name: 'gallery', query: { tag_id: tagId } })
+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
+48
View File
@@ -180,6 +180,54 @@ async def test_scroll_filters_by_platform(db):
assert [it["id"] for it in page["items"]] == [pp.id]
@pytest.mark.asyncio
async def test_scroll_text_search_matches_title_or_description(db):
artist = await _seed_artist(db, "alice-search")
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-se")
now = datetime.now(UTC)
by_title = await _seed_post(
db, src.id, external_id="T", post_date=now,
title="Dragon Quest cover", description="<p>nothing</p>",
)
by_desc = await _seed_post(
db, src.id, external_id="D", post_date=now - timedelta(days=1),
title="Untitled", description="<p>a sleeping dragon</p>",
)
await _seed_post(
db, src.id, external_id="N", post_date=now - timedelta(days=2),
title="Cat nap", description="<p>just a cat</p>",
)
await db.commit()
page = await PostFeedService(db).scroll(cursor=None, limit=10, q="dragon")
ids = {it["id"] for it in page["items"]}
# ILIKE is case-insensitive and spans title OR description; the cat post
# matches neither.
assert ids == {by_title.id, by_desc.id}
@pytest.mark.asyncio
async def test_scroll_text_search_stays_scoped_to_artist(db):
alice = await _seed_artist(db, "alice-scope")
bob = await _seed_artist(db, "bob-scope")
src_a = await _seed_source(db, alice.id, "patreon", "https://p/alice-sc")
src_b = await _seed_source(db, bob.id, "patreon", "https://p/bob-sc")
now = datetime.now(UTC)
alice_hit = await _seed_post(
db, src_a.id, external_id="AH", post_date=now, title="comet study",
)
# Same query word, different artist — must be excluded by the scope.
await _seed_post(
db, src_b.id, external_id="BH", post_date=now, title="comet sketch",
)
await db.commit()
page = await PostFeedService(db).scroll(
cursor=None, limit=10, q="comet", artist_id=alice.id,
)
assert [it["id"] for it in page["items"]] == [alice_hit.id]
@pytest.mark.asyncio
async def test_scroll_combined_artist_and_platform(db):
alice = await _seed_artist(db, "alice-combo")