The app uses a plain sticky TopNav (no v-main), and the nav's height was hardcoded as 64px in ~6 places: the Explore + Subscriptions full-height workspaces (height: calc(100vh - 64px)) and every sticky sub-header pinned beneath the nav (top: 64px — Gallery filter bar, Browse/Series/Settings tabs). Vuetify 4's MD3 sizing changed the real nav height, so 64px was wrong: the Explore workspace was sized taller than the space below the nav, overflowed the viewport, and its breadcrumb tucked under the (taller) nav on 1080p. TopNav now measures its own height via ResizeObserver and publishes it as --fc-nav-h on documentElement (default 64px in app.css). Every consumer uses var(--fc-nav-h) instead of the magic number, so the layout self-corrects to the nav's real height and stays correct as it reflows (per-view teleported actions, mobile breakpoint). Also tightens the new chrome-gradient seam — sub-headers now pin at the nav's exact bottom. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
6.7 KiB
Vue
185 lines
6.7 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 fc-chrome-continues">
|
|
<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: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
|
z-index: 4;
|
|
/* Background is the shared .fc-chrome-continues fade — it continues the nav's
|
|
gradient instead of a solid surface band (operator 2026-07-13). */
|
|
}
|
|
.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>
|