67c7ca8603
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>
417 lines
16 KiB
Vue
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>
|