feat(browse): sticky tabs + per-tab search bar (server-side, scope-aware)
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:
@@ -17,6 +17,7 @@ async def list_posts():
|
|||||||
cursor = args.get("cursor") or None
|
cursor = args.get("cursor") or None
|
||||||
artist_id_raw = args.get("artist_id")
|
artist_id_raw = args.get("artist_id")
|
||||||
platform = args.get("platform") or None
|
platform = args.get("platform") or None
|
||||||
|
q = (args.get("q") or "").strip() or None
|
||||||
limit_raw = args.get("limit", "24")
|
limit_raw = args.get("limit", "24")
|
||||||
direction = args.get("direction", "older")
|
direction = args.get("direction", "older")
|
||||||
around_raw = args.get("around")
|
around_raw = args.get("around")
|
||||||
@@ -56,7 +57,7 @@ async def list_posts():
|
|||||||
if around_id is not None:
|
if around_id is not None:
|
||||||
result = await svc.around(
|
result = await svc.around(
|
||||||
post_id=around_id, artist_id=artist_id,
|
post_id=around_id, artist_id=artist_id,
|
||||||
platform=platform, limit=limit,
|
platform=platform, q=q, limit=limit,
|
||||||
)
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
||||||
@@ -64,7 +65,7 @@ async def list_posts():
|
|||||||
try:
|
try:
|
||||||
page = await svc.scroll(
|
page = await svc.scroll(
|
||||||
cursor=cursor, artist_id=artist_id,
|
cursor=cursor, artist_id=artist_id,
|
||||||
platform=platform, limit=limit, direction=direction,
|
platform=platform, q=q, limit=limit, direction=direction,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# Service raises ValueError for malformed cursors only;
|
# Service raises ValueError for malformed cursors only;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class PostFeedService:
|
|||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
|
q: str | None = None,
|
||||||
limit: int = 24,
|
limit: int = 24,
|
||||||
direction: str = "older",
|
direction: str = "older",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -52,7 +53,12 @@ class PostFeedService:
|
|||||||
time (default, infinite-scroll down); direction='newer' walks forward
|
time (default, infinite-scroll down); direction='newer' walks forward
|
||||||
(scroll up in an anchored view). Items are always returned in feed
|
(scroll up in an anchored view). Items are always returned in feed
|
||||||
(descending) order; `next_cursor` points to the far edge in the
|
(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:
|
if limit < 1 or limit > 100:
|
||||||
raise ValueError("limit must be between 1 and 100")
|
raise ValueError("limit must be between 1 and 100")
|
||||||
if direction not in ("older", "newer"):
|
if direction not in ("older", "newer"):
|
||||||
@@ -74,6 +80,12 @@ class PostFeedService:
|
|||||||
stmt = stmt.where(Post.artist_id == artist_id)
|
stmt = stmt.where(Post.artist_id == artist_id)
|
||||||
if platform is not None:
|
if platform is not None:
|
||||||
stmt = stmt.where(Source.platform == platform)
|
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:
|
if cursor:
|
||||||
cur_ts, cur_id = decode_cursor(cursor)
|
cur_ts, cur_id = decode_cursor(cursor)
|
||||||
if direction == "older":
|
if direction == "older":
|
||||||
@@ -124,6 +136,7 @@ class PostFeedService:
|
|||||||
post_id: int,
|
post_id: int,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
|
q: str | None = None,
|
||||||
limit: int = 12,
|
limit: int = 12,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""A window centered on `post_id`: up to `limit` newer posts + the
|
"""A window centered on `post_id`: up to `limit` newer posts + the
|
||||||
@@ -143,11 +156,11 @@ class PostFeedService:
|
|||||||
|
|
||||||
older = await self.scroll(
|
older = await self.scroll(
|
||||||
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
||||||
limit=limit, direction="older",
|
q=q, limit=limit, direction="older",
|
||||||
)
|
)
|
||||||
newer = await self.scroll(
|
newer = await self.scroll(
|
||||||
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
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])
|
thumbs_map = await self._thumbnails_for([anchor_post.id])
|
||||||
atts_map = await self._attachments_for([anchor_post.id])
|
atts_map = await self._attachments_for([anchor_post.id])
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
const cursor = ref(null)
|
const cursor = ref(null)
|
||||||
const { loading, error, run } = useAsyncAction()
|
const { loading, error, run } = useAsyncAction()
|
||||||
const done = ref(false)
|
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
|
// 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.
|
// 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 (cursor.value) q.cursor = cursor.value
|
||||||
if (filters.value.artist_id != null) q.artist_id = filters.value.artist_id
|
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.platform) q.platform = filters.value.platform
|
||||||
|
if (filters.value.q) q.q = filters.value.q
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
filters.value = {
|
filters.value = {
|
||||||
artist_id: newFilters?.artist_id ?? null,
|
artist_id: newFilters?.artist_id ?? null,
|
||||||
platform: newFilters?.platform ?? null,
|
platform: newFilters?.platform ?? null,
|
||||||
|
q: newFilters?.q ?? null,
|
||||||
}
|
}
|
||||||
_reset()
|
_reset()
|
||||||
await loadMore()
|
await loadMore()
|
||||||
@@ -78,6 +80,7 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
const p = { ...extra }
|
const p = { ...extra }
|
||||||
if (filters.value.artist_id != null) p.artist_id = filters.value.artist_id
|
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.platform) p.platform = filters.value.platform
|
||||||
|
if (filters.value.q) p.q = filters.value.q
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
filters.value = {
|
filters.value = {
|
||||||
artist_id: newFilters?.artist_id ?? null,
|
artist_id: newFilters?.artist_id ?? null,
|
||||||
platform: newFilters?.platform ?? null,
|
platform: newFilters?.platform ?? null,
|
||||||
|
q: newFilters?.q ?? null,
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="pt-2 pb-6">
|
<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">
|
<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-select
|
||||||
v-model="platformModel"
|
v-model="platformModel"
|
||||||
:items="platformItems"
|
:items="platformItems"
|
||||||
@@ -34,26 +31,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useArtistDirectoryStore } from '../stores/artistDirectory.js'
|
||||||
import { usePlatformsStore } from '../stores/platforms.js'
|
import { usePlatformsStore } from '../stores/platforms.js'
|
||||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||||
import ArtistCard from '../components/discovery/ArtistCard.vue'
|
import ArtistCard from '../components/discovery/ArtistCard.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const store = useArtistDirectoryStore()
|
const store = useArtistDirectoryStore()
|
||||||
const platformsStore = usePlatformsStore()
|
const platformsStore = usePlatformsStore()
|
||||||
|
|
||||||
const search = ref('')
|
|
||||||
const platformModel = ref(null)
|
const platformModel = ref(null)
|
||||||
const sentinelEl = ref(null)
|
const sentinelEl = ref(null)
|
||||||
|
|
||||||
const platformItems = ref([])
|
const platformItems = ref([])
|
||||||
|
|
||||||
let debounce = null
|
// Search term comes from BrowseView's shared sticky bar via route.query.q.
|
||||||
watch(search, (v) => {
|
const searchTerm = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
|
||||||
if (debounce) clearTimeout(debounce)
|
watch(searchTerm, (v) => store.setQuery(v || ''))
|
||||||
debounce = setTimeout(() => store.setQuery(v || ''), 300)
|
|
||||||
})
|
|
||||||
watch(platformModel, (v) => store.setPlatform(v || null))
|
watch(platformModel, (v) => store.setPlatform(v || null))
|
||||||
|
|
||||||
useInfiniteScroll(sentinelEl, () => {
|
useInfiniteScroll(sentinelEl, () => {
|
||||||
@@ -65,7 +61,8 @@ onMounted(async () => {
|
|||||||
platformItems.value = (platformsStore.platforms || []).map(p => ({
|
platformItems.value = (platformsStore.platforms || []).map(p => ({
|
||||||
title: p.key, value: p.key,
|
title: p.key, value: p.key,
|
||||||
}))
|
}))
|
||||||
store.reset()
|
// Apply any deep-linked q (and load). setQuery resets + fetches.
|
||||||
|
store.setQuery(searchTerm.value || '')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-browse">
|
<div class="fc-browse">
|
||||||
<v-container fluid class="pt-2 pb-0">
|
<!-- Sticky browse chrome: the tab strip + a per-tab search bar, both pinned
|
||||||
<v-tabs v-model="tab" density="compact">
|
directly under the 64px TopNav (AppShell). Operator-asked 2026-06-11:
|
||||||
<v-tab value="posts">Posts</v-tab>
|
the tab nav scrolled away (they didn't know it existed), and there was
|
||||||
<v-tab value="artists">Artists</v-tab>
|
no search on Posts. Tabs + search live in ONE sticky block so the axis
|
||||||
<v-tab value="tags">Tags</v-tab>
|
switcher and the search stay reachable no matter how far you scroll.
|
||||||
</v-tabs>
|
Background uses the theme surface token so content scrolls cleanly
|
||||||
</v-container>
|
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
|
<!-- 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
|
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
|
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.
|
/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
|
(FC nav consolidation, operator-asked 2026-06-09: Posts/Artists/Tags are
|
||||||
the three "browse the library by an axis" surfaces.) -->
|
the three "browse the library by an axis" surfaces.) -->
|
||||||
<component :is="activeComponent" :key="tab" />
|
<component :is="activeComponent" :key="tab" />
|
||||||
@@ -19,17 +56,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { usePostsStore } from '../stores/posts.js'
|
||||||
import ArtistsView from './ArtistsView.vue'
|
import ArtistsView from './ArtistsView.vue'
|
||||||
import PostsView from './PostsView.vue'
|
import PostsView from './PostsView.vue'
|
||||||
import TagsView from './TagsView.vue'
|
import TagsView from './TagsView.vue'
|
||||||
|
|
||||||
const TABS = { posts: PostsView, artists: ArtistsView, tags: TagsView }
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const postsStore = usePostsStore()
|
||||||
|
|
||||||
const tab = computed({
|
const tab = computed({
|
||||||
get() {
|
get() {
|
||||||
@@ -38,10 +82,83 @@ const tab = computed({
|
|||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
// Switching tabs starts a clean tab — a posts deep link (post_id, etc.)
|
// 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 } })
|
router.replace({ name: 'browse', query: { tab: value } })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeComponent = computed(() => TABS[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>
|
</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>
|
||||||
|
|||||||
@@ -59,7 +59,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
|
<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>
|
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
||||||
tab to start capturing posts.
|
tab to start capturing posts.
|
||||||
</p>
|
</p>
|
||||||
@@ -93,10 +94,14 @@ const artistFilter = computed(() => {
|
|||||||
return raw == null ? null : Number(raw)
|
return raw == null ? null : Number(raw)
|
||||||
})
|
})
|
||||||
const platformFilter = computed(() => route.query.platform || null)
|
const platformFilter = computed(() => route.query.platform || null)
|
||||||
|
const searchFilter = computed(() => route.query.q || null)
|
||||||
const postIdFilter = computed(() => {
|
const postIdFilter = computed(() => {
|
||||||
const raw = route.query.post_id
|
const raw = route.query.post_id
|
||||||
return raw == null ? null : Number(raw)
|
return raw == null ? null : Number(raw)
|
||||||
})
|
})
|
||||||
|
const hasActiveFilter = computed(() =>
|
||||||
|
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
|
||||||
|
)
|
||||||
|
|
||||||
// --- normal feed (downward infinite scroll) ---
|
// --- normal feed (downward infinite scroll) ---
|
||||||
const sentinel = ref(null)
|
const sentinel = ref(null)
|
||||||
@@ -106,6 +111,7 @@ async function reload() {
|
|||||||
await store.loadInitial({
|
await store.loadInitial({
|
||||||
artist_id: artistFilter.value,
|
artist_id: artistFilter.value,
|
||||||
platform: platformFilter.value,
|
platform: platformFilter.value,
|
||||||
|
q: searchFilter.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function onFilters({ artist_id, platform }) {
|
function onFilters({ artist_id, platform }) {
|
||||||
@@ -169,6 +175,7 @@ async function loadAroundAndAnchor() {
|
|||||||
await store.loadAround(postIdFilter.value, {
|
await store.loadAround(postIdFilter.value, {
|
||||||
artist_id: artistFilter.value,
|
artist_id: artistFilter.value,
|
||||||
platform: platformFilter.value,
|
platform: platformFilter.value,
|
||||||
|
q: searchFilter.value,
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
||||||
@@ -188,7 +195,7 @@ async function activate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(postIdFilter, activate)
|
watch(postIdFilter, activate)
|
||||||
watch(() => [artistFilter.value, platformFilter.value], async () => {
|
watch(() => [artistFilter.value, platformFilter.value, searchFilter.value], async () => {
|
||||||
if (postIdFilter.value != null) return
|
if (postIdFilter.value != null) return
|
||||||
await reload()
|
await reload()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="pt-2 pb-6">
|
<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">
|
<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-chip-group
|
||||||
v-model="kind" selected-class="text-accent" :mandatory="false"
|
v-model="kind" selected-class="text-accent" :mandatory="false"
|
||||||
>
|
>
|
||||||
@@ -103,8 +100,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { computed, ref, watch, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
||||||
import { useAdminStore } from '../stores/admin.js'
|
import { useAdminStore } from '../stores/admin.js'
|
||||||
import { useApi } from '../composables/useApi.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).
|
// a tag. 'meta' + 'rating' retired by operator 2026-05-26 (alembic 0023).
|
||||||
const KINDS = ['character', 'fandom', 'general', 'series']
|
const KINDS = ['character', 'fandom', 'general', 'series']
|
||||||
const store = useTagDirectoryStore()
|
const store = useTagDirectoryStore()
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const search = ref('')
|
|
||||||
const kind = ref(null)
|
const kind = ref(null)
|
||||||
const sentinelEl = ref(null)
|
const sentinelEl = ref(null)
|
||||||
const pendingMerge = ref(null)
|
const pendingMerge = ref(null)
|
||||||
@@ -140,17 +137,16 @@ async function confirmMerge() {
|
|||||||
if (c) await store.merge(c.sourceId, c.target.id)
|
if (c) await store.merge(c.sourceId, c.target.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
let debounce = null
|
// Search term comes from BrowseView's shared sticky bar via route.query.q.
|
||||||
watch(search, (v) => {
|
const searchTerm = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
|
||||||
if (debounce) clearTimeout(debounce)
|
watch(searchTerm, (v) => store.setQuery(v || ''))
|
||||||
debounce = setTimeout(() => store.setQuery(v || ''), 300)
|
|
||||||
})
|
|
||||||
watch(kind, (v) => store.setKind(v || null))
|
watch(kind, (v) => store.setKind(v || null))
|
||||||
|
|
||||||
useInfiniteScroll(sentinelEl, () => {
|
useInfiniteScroll(sentinelEl, () => {
|
||||||
if (store.hasMore && !store.loading) store.loadMore()
|
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) {
|
function openTag(tagId) {
|
||||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ async def test_list_filter_propagates_artist(client, seeded_post):
|
|||||||
assert body["items"][0]["id"] == post.id
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_detail_200_for_known(client, seeded_post):
|
async def test_detail_200_for_known(client, seeded_post):
|
||||||
_, _, post = seeded_post
|
_, _, post = seeded_post
|
||||||
|
|||||||
@@ -180,6 +180,54 @@ async def test_scroll_filters_by_platform(db):
|
|||||||
assert [it["id"] for it in page["items"]] == [pp.id]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_scroll_combined_artist_and_platform(db):
|
async def test_scroll_combined_artist_and_platform(db):
|
||||||
alice = await _seed_artist(db, "alice-combo")
|
alice = await _seed_artist(db, "alice-combo")
|
||||||
|
|||||||
Reference in New Issue
Block a user