Files
FabledCurator/frontend/src/views/ExploreView.vue
T
bvandeusen 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

417 lines
16 KiB
Vue

<template>
<div class="fc-ex">
<!-- Seeding the bare /explore nav entry with a random image. -->
<div v-if="!anchorId && seeding" class="fc-ex__center-state">
<div class="fc-ex__spinner" />
<p class="fc-muted">Finding a random image to explore</p>
</div>
<!-- Seed failed (e.g. empty library) offer the gallery. -->
<div v-else-if="!anchorId && seedError" class="fc-ex__center-state">
<v-icon size="48" color="accent">mdi-compass-outline</v-icon>
<h2 class="fc-ex__empty-title">Nothing to explore yet</h2>
<p class="fc-ex__empty-body">{{ seedError }}</p>
<v-btn color="accent" variant="flat" rounded="pill" :to="{ name: 'gallery' }">
Browse the gallery
</v-btn>
</div>
<template v-else>
<!-- Breadcrumb of the walked path + reseed control. -->
<nav class="fc-ex__trail" aria-label="Explore path">
<button
v-for="(c, i) in store.breadcrumb" :key="c.id"
class="fc-ex__crumb"
:class="{ 'fc-ex__crumb--current': i === store.cursor }"
type="button"
:aria-current="i === store.cursor ? 'page' : undefined"
:title="`Step ${i + 1}`"
@click="goTo(c.id)"
>
<img :src="c.thumbnail_url" alt="" loading="lazy" />
</button>
<div class="fc-ex__trail-actions">
<!-- Reach (#1476): how far each step reaches past the anchor's cluster.
Raise it to break out of a dense signature without hitting Random. -->
<div
class="fc-ex__reach"
title="How far each step reaches — raise it to escape a dense cluster without going fully random"
>
<v-icon size="16" color="accent">mdi-map-marker-distance</v-icon>
<v-slider
:model-value="store.reach" @end="store.setReach"
:min="0" :max="1" :step="0.2" hide-details density="compact"
color="accent" class="fc-ex__reach-slider"
/>
<span class="fc-muted fc-ex__reach-label">{{ reachLabel }}</span>
</div>
<!-- Active retrain right where you tag: fold the +/- you just gave
into the heads without a trip to Settings (the nightly beat is the
passive cadence). -->
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-brain" :loading="headsBusy || headsRunning"
title="Retrain the concept heads on your latest tags"
@click="trainHeads"
>{{ headsRunning ? 'Training' : 'Retrain heads' }}</v-btn>
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-shuffle-variant" :loading="seeding"
@click="reseed"
>Random image</v-btn>
</div>
</nav>
<v-alert v-if="store.error" type="error" variant="tonal" class="ma-3">
Failed to load: {{ store.error }}
</v-alert>
<!-- Three panes: neighbours | viewer | tag rail. The image modal,
unfolded into a persistent surface so tagging while rabbit-holing is
fast (operator concept 2026-06-26). -->
<div class="fc-ex__panes">
<!-- LEFT: visual neighbours; click to walk. -->
<aside class="fc-ex__neighbors" aria-label="Visual neighbours">
<h3 class="fc-ex__rail-title">
Neighbours
<span v-if="store.neighbors.length" class="fc-muted">({{ store.neighbors.length }})</span>
</h3>
<div v-if="store.loading && !store.neighbors.length" class="fc-ex__grid">
<div v-for="n in 8" :key="n" class="fc-ex__skel" />
</div>
<p
v-else-if="store.anchor && !store.anchor.has_embedding"
class="fc-ex__note fc-muted"
>
No visual embedding yet (videos / not-yet-processed images), so
there are no neighbours to walk.
</p>
<p v-else-if="!store.neighbors.length" class="fc-ex__note fc-muted">
No visual neighbours found.
</p>
<div v-else class="fc-ex__grid">
<button
v-for="img in store.neighbors" :key="img.id"
class="fc-ex__tile" type="button"
:title="`Walk to image ${img.id}`"
@click="goTo(img.id)"
>
<img :src="img.thumbnail_url" :alt="`Neighbour ${img.id}`" loading="lazy" />
</button>
</div>
</aside>
<!-- CENTER: the focused image (light viewer) + meta. -->
<section class="fc-ex__viewer">
<div class="fc-ex__canvas">
<template v-if="store.anchor">
<!-- Videos can't render in an <img> branch to VideoCanvas like
the modal does (an MP4 in ImageCanvas just shows the alt). -->
<ImageCanvas
v-if="!isVideo"
:key="store.anchor.id"
:src="store.anchor.image_url"
:alt="`Image ${store.anchor.id}`"
:hover="hoverGrounding"
/>
<VideoCanvas
v-else
:key="store.anchor.id"
:src="store.anchor.image_url"
:mime="store.anchor.mime"
/>
</template>
</div>
<div v-if="store.anchor" class="fc-ex__viewer-foot">
<div class="fc-ex__artist">{{ store.anchor.artist?.name || 'Unknown artist' }}</div>
<ImageMetaBar :image="store.anchor" />
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-arrow-expand-all" @click="openInViewer(store.anchor.id)"
>Open full viewer</v-btn>
</div>
</section>
<!-- RIGHT: provenance (post/source often names the character) above
the modal's tag rail, both hosted on the anchor. -->
<aside class="fc-ex__rail" aria-label="Provenance and tags for the focused image">
<template v-if="store.currentImageId != null">
<ProvenancePanel :image-id="store.currentImageId" :image="store.anchor" />
<TagPanel ref="tagPanelRef" :host="store" />
</template>
</aside>
</div>
</template>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApi } from '../composables/useApi.js'
import { useExploreStore } from '../stores/explore.js'
import { useModalStore } from '../stores/modal.js'
import { useHeadTraining } from '../composables/useHeadTraining.js'
import { isTextEntry } from '../utils/textEntry.js'
import ImageCanvas from '../components/modal/ImageCanvas.vue'
import VideoCanvas from '../components/modal/VideoCanvas.vue'
import ImageMetaBar from '../components/modal/ImageMetaBar.vue'
import ProvenancePanel from '../components/modal/ProvenancePanel.vue'
import TagPanel from '../components/modal/TagPanel.vue'
const route = useRoute()
const router = useRouter()
const api = useApi()
const store = useExploreStore()
const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null)
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
const reachLabel = computed(() => {
const r = store.reach
if (r <= 0.15) return 'Near'
if (r <= 0.55) return 'Varied'
return 'Far'
})
// #1206: hovering a suggestion in the rail highlights the crop it came from on
// the anchor image (same provide/inject as the modal viewer).
const hoverGrounding = ref(null)
provide('fcSuggestionHover', hoverGrounding)
const seeding = ref(false)
const seedError = ref(null)
const tagPanelRef = ref(null)
// Inline head-retrain (shared with the Settings card) so banking your latest
// +/- feedback is one click while you walk content.
const {
running: headsRunning, busy: headsBusy, train: trainHeads,
start: startHeads, stop: stopHeads,
} = useHeadTraining()
// Auto-focus the tag input after any action so tagging needs no extra click —
// the whole point of the workspace (operator-asked 2026-06-26). nextTick waits
// for the post-navigation re-render, then rAF lands the focus AFTER paint so a
// neighbour/breadcrumb click that re-renders the grid can't steal it back.
// Reuses TagPanel/TagAutocomplete's focus, which keeps its mobile guard (no
// soft-keyboard pop on touch).
function refocusTag () {
nextTick(() => requestAnimationFrame(() => tagPanelRef.value?.focusTagInput?.()))
}
// Every focused-image change (seed + every walk: neighbour click, breadcrumb,
// Random image).
watch(() => store.currentImageId, (id) => { if (id != null) refocusTag() })
// The route is the source of truth. With an anchor, walk it; without one (the
// bare /explore nav entry) seed a RANDOM image so the tab kick-starts a rabbit
// hole instead of dead-ending on an empty state (operator-confirmed 2026-06-26).
watch(
anchorId,
(id) => {
if (id) { store.anchorOn(id); return }
store.reset()
seedRandom()
},
{ immediate: true },
)
async function seedRandom () {
if (seeding.value) return
seeding.value = true
seedError.value = null
try {
const body = await api.get('/api/showcase', { params: { limit: 1 } })
const img = body.images?.[0]
if (!img) {
seedError.value = 'Your library has no images to explore yet.'
return
}
router.replace({ name: 'explore', params: { imageId: String(img.id) } })
} catch (e) {
seedError.value = e.message
} finally {
seeding.value = false
}
}
// "Random image" jumps to a fresh seed from anywhere in the walk.
function reseed () {
router.push({ name: 'explore' })
// If already on bare /explore the param watch won't refire, so seed directly.
if (!anchorId.value) seedRandom()
}
function goTo (id) {
if (Number(id) === Number(anchorId.value)) return
router.push({ name: 'explore', params: { imageId: String(id) } })
}
function openInViewer (id) { modal.open(id) }
// Keyboard: T or "/" jumps to the tag input (modal-parity); ←/→ walk the
// breadcrumb trail — ← steps back, → goes forward to an already-visited item
// or, with no forward history, jumps to a random neighbour (keep rabbit-holing).
function onKeyDown (ev) {
if (ev.metaKey || ev.ctrlKey || ev.altKey) return
const inText = isTextEntry(ev.target)
if (ev.key === 't' || ev.key === '/') {
if (!inText) {
const input = document.querySelector('.fc-tag-autocomplete input')
if (input) { ev.preventDefault(); input.focus() }
}
return
}
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return
// The tag input auto-focuses after every walk, so also allow navigation while
// it's focused-but-EMPTY (the caret has nowhere to go) — otherwise arrow-nav
// would dead-end after one step. Once a tag is being typed, arrows move the
// caret instead.
const t = ev.target
const inEmptyTagInput =
inText && t.value === '' && t.closest?.('.fc-tag-autocomplete')
if (inText && !inEmptyTagInput) return
const id = ev.key === 'ArrowLeft' ? store.backTarget() : store.forwardTarget()
if (id != null) { ev.preventDefault(); goTo(id) }
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
startHeads() // reflect a run already in flight (e.g. the nightly one)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
stopHeads()
})
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
/* Full-height workspace under the sticky top nav. --fc-nav-h is the nav's REAL
measured height (set by TopNav) — a hardcoded 64px here overflowed the
viewport under Vuetify 4's taller nav and tucked the breadcrumb under it
(#1481). Panes scroll internally, so an exact fit keeps everything on screen. */
.fc-ex {
display: flex; flex-direction: column;
height: calc(100vh - var(--fc-nav-h, 64px));
min-height: 0;
}
.fc-ex__center-state {
margin: auto; max-width: 520px; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 14px;
padding: 32px;
}
.fc-ex__empty-title { font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
.fc-ex__empty-body { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-ex__trail {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
padding: 8px 12px; flex: 0 0 auto;
}
.fc-ex__crumb {
width: 40px; height: 40px; border-radius: 8px; overflow: hidden;
border: 2px solid transparent; cursor: pointer; padding: 0;
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
}
.fc-ex__crumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-ex__crumb--current { border-color: rgb(var(--v-theme-accent)); }
.fc-ex__crumb:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.fc-ex__trail-actions {
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
}
.fc-ex__reach {
display: flex; align-items: center; gap: 6px;
margin-right: 8px;
}
.fc-ex__reach-slider { width: 96px; }
.fc-ex__reach-label {
font-size: 12px; min-width: 44px; text-align: left;
}
/* The three panes fill the remaining height; each scrolls on its own.
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
height — without it the row is `auto` and a tall pane (Provenance + Tags +
a long Suggestions list) grows the row past the fixed-height view, spilling
over and scrolling the whole page (the weird top gap). Bounded, the panes'
own overflow-y:auto kicks in and they scroll internally instead. */
.fc-ex__panes {
flex: 1 1 auto; min-height: 0;
display: grid;
grid-template-columns: 340px minmax(0, 1fr) var(--fc-side-w, 320px);
grid-template-rows: minmax(0, 1fr);
gap: 1px;
background: rgb(var(--v-theme-surface-light));
}
.fc-ex__neighbors {
background: rgb(var(--v-theme-background));
overflow-y: auto; padding: 12px;
}
.fc-ex__rail-title {
font-size: 14px; font-weight: 600; margin: 2px 0 10px;
}
.fc-ex__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
}
.fc-ex__tile {
aspect-ratio: 1; border-radius: 8px; overflow: hidden; padding: 0;
border: 2px solid transparent; cursor: pointer;
background: rgb(var(--v-theme-surface-light));
}
.fc-ex__tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-ex__tile:hover { border-color: rgba(var(--v-theme-accent), 0.6); }
.fc-ex__tile:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.fc-ex__skel {
aspect-ratio: 1; border-radius: 8px;
background: rgb(var(--v-theme-surface-light)); animation: fc-pulse 1.4s ease-in-out infinite;
}
.fc-ex__note { padding: 16px 4px; }
/* Center viewer. */
.fc-ex__viewer {
background: rgb(20, 23, 26);
display: flex; flex-direction: column; min-width: 0; min-height: 0;
}
.fc-ex__canvas { flex: 1 1 auto; display: flex; min-height: 0; min-width: 0; }
.fc-ex__viewer-foot {
flex: 0 0 auto;
border-top: 1px solid rgb(var(--v-theme-surface-light));
padding-bottom: 10px;
}
.fc-ex__artist { padding: 10px 16px 0; font-weight: 600; }
/* Right rail = the modal's tag panel, hosted on the anchor. The bottom padding
keeps the scrollable content from reaching the viewport floor, so the
bottom-right snackbar floats over empty space instead of covering the last
suggestions / their accept-reject controls (operator-flagged 2026-07-06). */
.fc-ex__rail {
background: rgb(var(--v-theme-surface));
overflow-y: auto;
padding-bottom: 88px;
}
.fc-ex__spinner {
width: 64px; height: 64px; border-radius: 50%;
border: 5px solid rgb(var(--v-theme-accent), 0.16);
border-top-color: rgb(var(--v-theme-accent));
animation: fc-ex-spin 0.95s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
}
@keyframes fc-ex-spin { to { transform: rotate(360deg); } }
@keyframes fc-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.9; } }
@media (prefers-reduced-motion: reduce) { .fc-ex__spinner { animation-duration: 3s; } }
/* Narrow: stack viewer → tags → neighbours, page scrolls. */
@media (max-width: 1100px) {
.fc-ex { height: auto; }
.fc-ex__panes {
grid-template-columns: 1fr;
/* Stacked: let each pane take its natural height and the page scroll. */
grid-template-rows: none;
background: transparent;
}
.fc-ex__viewer { order: -1; min-height: 60vh; }
.fc-ex__neighbors { order: 1; }
}
</style>