Files
FabledCurator/frontend/src/views/BrowseView.vue
T
bvandeusenandClaude Opus 4.8 67c7ca8603
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m44s
fix(ui): measure real nav height (--fc-nav-h), stop Explore breadcrumb tucking under nav (#1481)
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>
2026-07-13 15:27:37 -04:00

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>