3e1303ea3c
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>
184 lines
6.6 KiB
Vue
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>
|