feat(explore): Explore view + tag-gap closing + modal meta/download (#94b–d, #4a/b)
Cluster C frontend, milestone #94. #94b Explore walk: new /explore/:imageId route + ExploreView + explore store. Anchor (reuse /api/gallery/image), neighbour grid (reuse /api/gallery/similar, 24), click a neighbour to re-anchor; in-memory breadcrumb that trims on backtrack (route is the source of truth). Empty/loading/error + no-embedding states. #94c tag-gap closing: components/explore/TagGapPanel — fetches /api/images/cluster/tag-gaps for the anchor+neighbours, a consensus-threshold slider (default 60%), per gap shows present/total + the missing thumbnails + 'Apply to N missing' → /api/tags/images/bulk/tags (source manual) → re-fetch. #94d entry points: 'Explore' button in the modal RelatedStrip; the TopNav entry comes free from the route's meta.title. #4a metadata HUD + #4b split Download: new modal ImageMetaBar (always-on, above ProvenancePanel) shows dimensions/size/type and a split Download button (default Download, chevron → Copy link via utils/clipboard — no clipboard-image, rule 95). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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 }"
|
||||
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>
|
||||
</nav>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" class="my-4">
|
||||
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
|
||||
<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>
|
||||
<div
|
||||
v-else-if="store.anchor && !store.anchor.has_embedding"
|
||||
class="fc-explore__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 neighbours found.
|
||||
</div>
|
||||
<div v-else class="fc-explore__grid">
|
||||
<button
|
||||
v-for="img in store.neighbors" :key="img.id"
|
||||
class="fc-explore__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>
|
||||
|
||||
<!-- Right rail: cluster tag-gap closing (#94c). -->
|
||||
<aside class="fc-explore__rail">
|
||||
<TagGapPanel />
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useExploreStore()
|
||||
const tagStore = useTagStore()
|
||||
const modal = useModalStore()
|
||||
|
||||
const anchorId = computed(() => route.params.imageId || 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.
|
||||
watch(
|
||||
anchorId,
|
||||
(id) => { if (id) store.anchorOn(id); else store.reset() },
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function goTo (id) {
|
||||
if (Number(id) === Number(anchorId.value)) return
|
||||
router.push({ name: 'explore', params: { imageId: String(id) } })
|
||||
}
|
||||
|
||||
function openInViewer (id) { modal.open(id) }
|
||||
</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;
|
||||
}
|
||||
.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-explore__crumb {
|
||||
width: 44px; height: 44px; 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-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; }
|
||||
}
|
||||
|
||||
.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-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-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));
|
||||
gap: 8px;
|
||||
}
|
||||
.fc-explore__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 {
|
||||
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; }
|
||||
@keyframes fc-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.9; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user