feat(explore): 3-pane tagging workspace — gallery | viewer | tag rail
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 3m18s

Reworks Explore from "anchor + neighbour grid + cluster tag-gap rail" into a
persistent 3-pane workspace that unfolds the image modal so you can tag while
rabbit-holing (operator concept 2026-06-26):

- LEFT  neighbour grid (larger thumbs), click = walk; breadcrumb retained.
- CENTER light viewer — reuses ImageCanvas + ImageMetaBar(:image) for the
  focused image; "Open full viewer" still launches the overlay modal.
- RIGHT  the modal's TagPanel, hosted on the anchor for modal-parity tagging
  (chips, autocomplete, suggestions + Accept, fandom-on-chip, T/"/" focus).

Reuse without destabilising the audited modal store: TagPanel and
SuggestionsPanel gain an optional `host` prop (default = modal store, so the
image modal is unchanged); the explore store implements the same small
tag-CRUD surface (current/currentImageId + reloadTags/addExistingTag/
removeTag/createAndAdd) over the anchor. ImageMetaBar gains an optional
`image` prop for the same reason.

Drops the mass/cluster tagger (TagGapPanel deleted; clusterIds/thumbById
removed) — per-image tagging feeds the per-tag reference-embedding centroid
better than bulk ops.

Nav: keep the Explore tab but bare /explore now SEEDS a random image
(GET /api/showcase?limit=1 → /explore/:id) so the tab kick-starts a rabbit
hole; explicit meta.navOrder pins nav order (Explore after Gallery) since
router.getRoutes() doesn't preserve declaration order.

Note: the backend cluster tag-gaps route/service (#94a) is now frontend-orphaned
— left in place; flag for a separate cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 01:15:11 -04:00
parent 1aadf3267b
commit 2d1cddd9b7
8 changed files with 337 additions and 320 deletions
+200 -110
View File
@@ -1,26 +1,28 @@
<template>
<v-container fluid class="pt-2 pb-8 fc-explore">
<!-- Empty state: no anchor (the bare /explore nav entry). -->
<div v-if="!anchorId" class="fc-explore__empty">
<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-explore__empty-title">Explore by visual similarity</h2>
<p class="fc-explore__empty-body">
Open any image and choose <strong>Explore similar</strong>, or pick one
from the gallery to start walking its visual neighbours and closing
tag-coverage gaps across the cluster.
</p>
<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. -->
<nav v-if="store.breadcrumb.length > 1" class="fc-explore__trail" aria-label="Explore path">
<!-- 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-explore__crumb"
:class="{ 'fc-explore__crumb--current': i === store.breadcrumb.length - 1 }"
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}`"
@@ -28,175 +30,263 @@
>
<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="my-4">
<v-alert v-if="store.error" type="error" variant="tonal" class="ma-3">
Failed to load: {{ store.error }}
</v-alert>
<div class="fc-explore__layout">
<div class="fc-explore__main">
<!-- Anchor -->
<section v-if="store.anchor" class="fc-explore__anchor">
<img
:src="store.anchor.thumbnail_url" :alt="`Anchor image ${store.anchor.id}`"
class="fc-explore__anchor-img"
/>
<div class="fc-explore__anchor-meta">
<div class="fc-explore__anchor-title">
{{ store.anchor.artist?.name || 'Unknown artist' }}
</div>
<div class="fc-explore__chips">
<v-chip
v-for="t in store.anchor.tags || []" :key="t.id"
size="x-small" variant="tonal" :color="tagStore.colorFor(t.kind)"
>{{ t.name }}</v-chip>
<span v-if="!store.anchor.tags?.length" class="fc-muted text-caption">No tags yet.</span>
</div>
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-image-outline" @click="openInViewer(store.anchor.id)"
>Open in viewer</v-btn>
</div>
</section>
<!-- Neighbour grid -->
<h3 class="fc-explore__section-title">
Visual neighbours
<!-- 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-explore__grid">
<div v-for="n in 12" :key="n" class="fc-explore__skel" />
<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>
<div
<p
v-else-if="store.anchor && !store.anchor.has_embedding"
class="fc-explore__note fc-muted"
class="fc-ex__note fc-muted"
>
This image has no visual embedding yet (videos and not-yet-processed
images), so there are no neighbours to walk.
</div>
<div v-else-if="!store.neighbors.length" class="fc-explore__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.
</div>
<div v-else class="fc-explore__grid">
</p>
<div v-else class="fc-ex__grid">
<button
v-for="img in store.neighbors" :key="img.id"
class="fc-explore__tile" type="button"
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>
</div>
</aside>
<!-- Right rail: cluster tag-gap closing (#94c). -->
<aside class="fc-explore__rail">
<TagGapPanel />
<!-- 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: the modal's tag rail, hosted on the anchor. -->
<aside class="fc-ex__rail" aria-label="Tags for the focused image">
<TagPanel v-if="store.currentImageId != null" :host="store" />
</aside>
</div>
</template>
</v-container>
</div>
</template>
<script setup>
import { computed, watch } from 'vue'
import { computed, 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 { useTagStore } from '../stores/tags.js'
import { useModalStore } from '../stores/modal.js'
import TagGapPanel from '../components/explore/TagGapPanel.vue'
import { isTextEntry } from '../utils/textEntry.js'
import ImageCanvas from '../components/modal/ImageCanvas.vue'
import ImageMetaBar from '../components/modal/ImageMetaBar.vue'
import TagPanel from '../components/modal/TagPanel.vue'
const route = useRoute()
const router = useRouter()
const api = useApi()
const store = useExploreStore()
const tagStore = useTagStore()
const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null)
const seeding = ref(false)
const seedError = ref(null)
// The route is the source of truth — anchor on whatever the URL says, on mount
// and on every param change (forward walk + breadcrumb backtrack both push the
// route). Reset the walk when we leave the anchored state entirely.
// 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); else store.reset() },
(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)); }
.fc-explore__empty {
max-width: 520px; margin: 64px auto; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 14px;
/* Full-height workspace under the sticky top nav. */
.fc-ex {
display: flex; flex-direction: column;
height: calc(100vh - 64px);
min-height: 0;
}
.fc-explore__empty-title { font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
.fc-explore__empty-body { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-explore__trail {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
padding: 4px 0 12px;
.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-explore__crumb {
width: 44px; height: 44px; border-radius: 8px; overflow: hidden;
.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-explore__crumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-explore__crumb--current { border-color: rgb(var(--v-theme-accent)); }
.fc-explore__crumb:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.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; }
.fc-explore__layout { display: flex; gap: 20px; align-items: flex-start; }
.fc-explore__main { flex: 1 1 auto; min-width: 0; }
.fc-explore__rail { flex: 0 0 320px; position: sticky; top: 80px; }
@media (max-width: 960px) {
.fc-explore__layout { flex-direction: column; }
.fc-explore__rail { flex-basis: auto; width: 100%; position: static; }
/* The three panes fill the remaining height; each scrolls on its own. */
.fc-ex__panes {
flex: 1 1 auto; min-height: 0;
display: grid;
grid-template-columns: 340px minmax(0, 1fr) var(--fc-side-w, 320px);
gap: 1px;
background: rgb(var(--v-theme-surface-light));
}
.fc-explore__anchor {
display: flex; gap: 16px; margin-bottom: 20px;
padding: 12px; border-radius: 12px;
background: rgba(var(--v-theme-on-surface), 0.03);
border: 1px solid rgba(var(--v-theme-on-surface), 0.10);
.fc-ex__neighbors {
background: rgb(var(--v-theme-background));
overflow-y: auto; padding: 12px;
}
.fc-explore__anchor-img {
width: 160px; height: 160px; object-fit: cover; border-radius: 8px;
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
.fc-ex__rail-title {
font-size: 14px; font-weight: 600; margin: 2px 0 10px;
}
.fc-explore__anchor-meta { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.fc-explore__anchor-title { font-weight: 600; }
.fc-explore__chips { display: flex; flex-wrap: wrap; gap: 4px; }
.fc-explore__section-title {
font-size: 14px; font-weight: 600; margin: 4px 0 10px;
}
.fc-explore__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
.fc-ex__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
}
.fc-explore__tile {
.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-explore__tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-explore__tile:hover { border-color: rgba(var(--v-theme-accent), 0.6); }
.fc-explore__tile:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.fc-explore__skel {
.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-explore__note { padding: 24px 0; }
.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;
background: transparent;
}
.fc-ex__viewer { order: -1; min-height: 60vh; }
.fc-ex__neighbors { order: 1; }
}
</style>