Files
FabledCurator/frontend/src/views/BrowseView.vue
T
bvandeusen 3e1303ea3c
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m12s
fix(browse): put tabs and search on one row
Operator-asked: the tab strip and search field were stacked; place them
side-by-side in a single flex bar (tabs left, search + scope chips right),
wrapping to two rows only on narrow viewports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:25:03 -04:00

184 lines
6.6 KiB
Vue

<template>
<div class="fc-browse">
<!-- 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">
<!-- Tabs and search share one row: the axis switcher on the left, the
search field + active-scope chips on the right (operator-asked
2026-06-12). Wraps to two rows only on narrow viewports. -->
<div class="fc-browse__bar">
<v-tabs
v-model="tab" density="compact" color="accent"
class="fc-browse__tabs"
>
<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>
</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" />
</div>
</template>
<script setup>
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() {
const t = route.query.tab
return typeof t === 'string' && t in TABS ? t : 'posts'
},
set(value) {
// Switching tabs starts a clean tab — a posts deep link (post_id, etc.)
// 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__bar {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
padding: 8px 0;
}
.fc-browse__tabs {
flex: 0 0 auto;
width: auto;
}
.fc-browse__search {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex: 1 1 320px;
}
.fc-browse__search-field {
max-width: 360px;
flex: 1 1 240px;
}
</style>