d63dfa511a
The panes grid had no explicit row, so the implicit `auto` row sized to its tallest pane's content. With Provenance + Tags + a long Suggestions list, the rail outgrew the fixed-height workspace, spilled over and made the WHOLE page scrollable — showing as a weird empty gap at the top (operator-flagged 2026-06-26). grid-template-rows: minmax(0, 1fr) bounds the row to the container so each pane's own overflow-y:auto scrolls internally instead. Reset to `none` in the stacked (<=1100px) layout where the page is meant to scroll. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
320 lines
12 KiB
Vue
320 lines
12 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.breadcrumb.length - 1 }"
|
|
type="button"
|
|
:aria-current="i === store.breadcrumb.length - 1 ? 'page' : undefined"
|
|
:title="`Step ${i + 1}`"
|
|
@click="goTo(c.id)"
|
|
>
|
|
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
|
</button>
|
|
<v-btn
|
|
class="fc-ex__reseed" size="small" variant="text" color="accent"
|
|
prepend-icon="mdi-shuffle-variant" :loading="seeding"
|
|
@click="reseed"
|
|
>Random image</v-btn>
|
|
</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">
|
|
<ImageCanvas
|
|
v-if="store.anchor"
|
|
:key="store.anchor.id"
|
|
:src="store.anchor.image_url"
|
|
:alt="`Image ${store.anchor.id}`"
|
|
/>
|
|
</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, 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 { isTextEntry } from '../utils/textEntry.js'
|
|
import ImageCanvas from '../components/modal/ImageCanvas.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 seeding = ref(false)
|
|
const seedError = ref(null)
|
|
const tagPanelRef = ref(null)
|
|
|
|
// 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) }
|
|
|
|
// Modal-parity focus: T or "/" jumps to the tag input (the same shortcut the
|
|
// image modal binds), unless the caret is already in a text field.
|
|
function onKeyDown (ev) {
|
|
if ((ev.key === 't' || ev.key === '/') && !isTextEntry(ev.target)) {
|
|
const input = document.querySelector('.fc-tag-autocomplete input')
|
|
if (input) { ev.preventDefault(); input.focus() }
|
|
}
|
|
}
|
|
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
|
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
|
|
/* Full-height workspace under the sticky top nav. */
|
|
.fc-ex {
|
|
display: flex; flex-direction: column;
|
|
height: calc(100vh - 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__reseed { margin-left: auto; }
|
|
|
|
/* 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. */
|
|
.fc-ex__rail {
|
|
background: rgb(var(--v-theme-surface));
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.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>
|