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,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-gaps">
|
||||||
|
<h3 class="fc-gaps__title">Tag-coverage gaps</h3>
|
||||||
|
<p class="fc-gaps__hint fc-muted">
|
||||||
|
Tags most of this cluster shares but some images lack. Apply to close the
|
||||||
|
gap across the stragglers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="fc-gaps__threshold">
|
||||||
|
<label class="text-caption fc-muted">
|
||||||
|
Consensus ≥ {{ Math.round(threshold * 100) }}%
|
||||||
|
</label>
|
||||||
|
<v-slider
|
||||||
|
v-model="threshold"
|
||||||
|
:min="0.3" :max="0.95" :step="0.05"
|
||||||
|
density="compact" hide-details color="accent"
|
||||||
|
aria-label="Consensus threshold"
|
||||||
|
@update:model-value="onThresholdInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="fc-gaps__state">
|
||||||
|
<v-progress-circular indeterminate size="22" width="2" color="accent" />
|
||||||
|
</div>
|
||||||
|
<v-alert v-else-if="error" type="error" variant="tonal" density="compact" class="mt-2">
|
||||||
|
{{ error }}
|
||||||
|
</v-alert>
|
||||||
|
<p v-else-if="!store.clusterIds.length" class="fc-gaps__state fc-muted text-body-2">
|
||||||
|
Anchor on an image with visual neighbours to see gaps.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!gaps.length" class="fc-gaps__state fc-muted text-body-2">
|
||||||
|
No gaps at this threshold — this cluster is consistently tagged.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="fc-gaps__list">
|
||||||
|
<div v-for="g in gaps" :key="g.tag_id" class="fc-gap">
|
||||||
|
<div class="fc-gap__head">
|
||||||
|
<span class="fc-gap__name">{{ g.name }}</span>
|
||||||
|
<span class="fc-gap__count fc-muted" :title="`${g.present_count} of ${g.total} images have this tag`">
|
||||||
|
{{ g.present_count }}/{{ g.total }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fc-gap__missing">
|
||||||
|
<img
|
||||||
|
v-for="iid in g.missing_image_ids.slice(0, MISSING_PREVIEW)" :key="iid"
|
||||||
|
:src="store.thumbById[iid]" alt="" loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="g.missing_image_ids.length > MISSING_PREVIEW"
|
||||||
|
class="fc-muted text-caption"
|
||||||
|
>+{{ g.missing_image_ids.length - MISSING_PREVIEW }}</span>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
size="small" color="accent" variant="tonal" rounded="pill"
|
||||||
|
:loading="applyingId === g.tag_id"
|
||||||
|
prepend-icon="mdi-tag-plus"
|
||||||
|
@click="applyGap(g)"
|
||||||
|
>Apply to {{ g.missing_image_ids.length }} missing</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useExploreStore } from '../../stores/explore.js'
|
||||||
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { useInflightToken } from '../../composables/useInflightToken.js'
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
|
|
||||||
|
const MISSING_PREVIEW = 8
|
||||||
|
|
||||||
|
const store = useExploreStore()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const threshold = ref(0.6)
|
||||||
|
const gaps = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const applyingId = ref(null)
|
||||||
|
|
||||||
|
const inflight = useInflightToken()
|
||||||
|
let debounce = null
|
||||||
|
|
||||||
|
async function fetchGaps () {
|
||||||
|
const ids = store.clusterIds
|
||||||
|
if (ids.length < 2) { gaps.value = []; return }
|
||||||
|
inflight.cancel()
|
||||||
|
const t = inflight.claim()
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const body = await api.post('/api/images/cluster/tag-gaps', {
|
||||||
|
body: { image_ids: ids, threshold: threshold.value },
|
||||||
|
})
|
||||||
|
if (!t.isCurrent()) return
|
||||||
|
gaps.value = body.gaps || []
|
||||||
|
} catch (e) {
|
||||||
|
if (t.isCurrent()) { error.value = e.message; gaps.value = [] }
|
||||||
|
} finally {
|
||||||
|
if (t.isCurrent()) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThresholdInput () {
|
||||||
|
if (debounce) clearTimeout(debounce)
|
||||||
|
debounce = setTimeout(fetchGaps, 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-evaluate gaps whenever the cluster changes (new anchor / new neighbours).
|
||||||
|
watch(() => store.clusterIds.join(','), fetchGaps, { immediate: true })
|
||||||
|
|
||||||
|
async function applyGap (g) {
|
||||||
|
applyingId.value = g.tag_id
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/tags/images/bulk/tags', {
|
||||||
|
body: { image_ids: g.missing_image_ids, tag_id: g.tag_id, source: 'manual' },
|
||||||
|
})
|
||||||
|
toast({
|
||||||
|
text: `Applied “${g.name}” to ${res.added_count ?? g.missing_image_ids.length} image(s)`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
await fetchGaps() // the gap shrinks/closes — refresh
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `Couldn't apply “${g.name}”: ${e.message}`, type: 'error' })
|
||||||
|
} finally {
|
||||||
|
applyingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
.fc-gaps {
|
||||||
|
padding: 14px; border-radius: 12px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.10);
|
||||||
|
}
|
||||||
|
.fc-gaps__title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif; font-size: 16px; font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.fc-gaps__hint { font-size: 12px; margin-bottom: 10px; }
|
||||||
|
.fc-gaps__threshold { margin-bottom: 8px; }
|
||||||
|
.fc-gaps__state { display: flex; justify-content: center; padding: 20px 0; }
|
||||||
|
.fc-gaps__list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.fc-gap {
|
||||||
|
padding: 10px; border-radius: 10px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
|
}
|
||||||
|
.fc-gap__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
|
||||||
|
.fc-gap__name { font-weight: 600; }
|
||||||
|
.fc-gap__count { font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||||
|
.fc-gap__missing { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.fc-gap__missing img {
|
||||||
|
width: 36px; height: 36px; object-fit: cover; border-radius: 5px;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<section v-if="img" class="fc-meta" aria-label="Image details">
|
||||||
|
<!-- #4a: dimensions / size / type — already on the payload, shown nowhere
|
||||||
|
until now. -->
|
||||||
|
<dl class="fc-meta__grid">
|
||||||
|
<div v-if="img.width && img.height" class="fc-meta__item">
|
||||||
|
<dt>Dimensions</dt><dd>{{ img.width }} × {{ img.height }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="img.size_bytes" class="fc-meta__item">
|
||||||
|
<dt>Size</dt><dd>{{ humanSize(img.size_bytes) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="img.mime" class="fc-meta__item">
|
||||||
|
<dt>Type</dt><dd>{{ shortType(img.mime) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- #4b: split Download — default Download, chevron → Copy link. Image-to-
|
||||||
|
clipboard is OFF the table on the plain-HTTP origin (rule 95), so the
|
||||||
|
secondary action copies the link TEXT, which works everywhere. -->
|
||||||
|
<v-btn-group divided density="comfortable" variant="tonal" color="accent" rounded="pill">
|
||||||
|
<v-btn prepend-icon="mdi-download" @click="download">Download</v-btn>
|
||||||
|
<v-menu location="bottom end">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" icon="mdi-chevron-down" aria-label="More download options" />
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item prepend-icon="mdi-link-variant" title="Copy link" @click="copyLink" />
|
||||||
|
<v-list-item prepend-icon="mdi-download" title="Download" @click="download" />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</v-btn-group>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
|
import { copyText } from '../../utils/clipboard.js'
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
|
|
||||||
|
const modal = useModalStore()
|
||||||
|
const img = computed(() => modal.current)
|
||||||
|
|
||||||
|
function humanSize (bytes) {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let n = bytes
|
||||||
|
let i = 0
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||||
|
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`
|
||||||
|
}
|
||||||
|
function shortType (mime) { return mime.split('/')[1]?.toUpperCase() || mime }
|
||||||
|
|
||||||
|
function absoluteUrl () {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||||
|
return origin + img.value.image_url
|
||||||
|
}
|
||||||
|
|
||||||
|
function download () {
|
||||||
|
// Same-origin /images/* link — the download attribute lets the browser save
|
||||||
|
// the original (filename derived from the path) instead of navigating to it.
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = img.value.image_url
|
||||||
|
a.setAttribute('download', '')
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLink () {
|
||||||
|
try {
|
||||||
|
await copyText(absoluteUrl())
|
||||||
|
toast({ text: 'Link copied to clipboard', type: 'success' })
|
||||||
|
} catch {
|
||||||
|
toast({ text: 'Could not copy the link', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-meta {
|
||||||
|
padding: 14px 16px 0;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.fc-meta__grid {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 0;
|
||||||
|
}
|
||||||
|
.fc-meta__item { display: flex; flex-direction: column; }
|
||||||
|
.fc-meta__item dt {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
.fc-meta__item dd {
|
||||||
|
margin: 0; font-size: 13px; font-variant-numeric: tabular-nums;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside v-if="modal.current" class="fc-viewer__side">
|
<aside v-if="modal.current" class="fc-viewer__side">
|
||||||
|
<ImageMetaBar />
|
||||||
<ProvenancePanel />
|
<ProvenancePanel />
|
||||||
<TagPanel />
|
<TagPanel />
|
||||||
<!-- Non-blocking: fetches its own similar set after the modal is up;
|
<!-- Non-blocking: fetches its own similar set after the modal is up;
|
||||||
@@ -90,6 +91,7 @@ import { useModalStore } from '../../stores/modal.js'
|
|||||||
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
|
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
|
||||||
import ImageCanvas from './ImageCanvas.vue'
|
import ImageCanvas from './ImageCanvas.vue'
|
||||||
import VideoCanvas from './VideoCanvas.vue'
|
import VideoCanvas from './VideoCanvas.vue'
|
||||||
|
import ImageMetaBar from './ImageMetaBar.vue'
|
||||||
import TagPanel from './TagPanel.vue'
|
import TagPanel from './TagPanel.vue'
|
||||||
import ProvenancePanel from './ProvenancePanel.vue'
|
import ProvenancePanel from './ProvenancePanel.vue'
|
||||||
import RelatedStrip from './RelatedStrip.vue'
|
import RelatedStrip from './RelatedStrip.vue'
|
||||||
|
|||||||
@@ -5,11 +5,18 @@
|
|||||||
<div v-if="show" class="fc-related">
|
<div v-if="show" class="fc-related">
|
||||||
<div class="fc-related__head">
|
<div class="fc-related__head">
|
||||||
<span class="fc-related__title">Related</span>
|
<span class="fc-related__title">Related</span>
|
||||||
<v-btn
|
<div class="fc-related__actions">
|
||||||
size="x-small" variant="text" color="accent"
|
<v-btn
|
||||||
:disabled="loading || !results.length"
|
size="x-small" variant="text" color="accent"
|
||||||
@click="seeAll"
|
prepend-icon="mdi-compass-outline"
|
||||||
>See all similar</v-btn>
|
@click="explore"
|
||||||
|
>Explore</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="x-small" variant="text" color="accent"
|
||||||
|
:disabled="loading || !results.length"
|
||||||
|
@click="seeAll"
|
||||||
|
>See all similar</v-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-related__row">
|
<div class="fc-related__row">
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
@@ -94,6 +101,14 @@ function seeAll() {
|
|||||||
modal.close()
|
modal.close()
|
||||||
router.push({ name: 'gallery', query: { similar_to: String(id) } })
|
router.push({ name: 'gallery', query: { similar_to: String(id) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #94: leave the modal and open the dedicated Explore walk anchored here.
|
||||||
|
function explore() {
|
||||||
|
const id = modal.current?.id
|
||||||
|
if (!id) return
|
||||||
|
modal.close()
|
||||||
|
router.push({ name: 'explore', params: { imageId: String(id) } })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -105,6 +120,7 @@ function seeAll() {
|
|||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
.fc-related__actions { display: flex; align-items: center; gap: 2px; }
|
||||||
.fc-related__title {
|
.fc-related__title {
|
||||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
|
|||||||
import SettingsView from './views/SettingsView.vue'
|
import SettingsView from './views/SettingsView.vue'
|
||||||
import GalleryView from './views/GalleryView.vue'
|
import GalleryView from './views/GalleryView.vue'
|
||||||
import ShowcaseView from './views/ShowcaseView.vue'
|
import ShowcaseView from './views/ShowcaseView.vue'
|
||||||
|
import ExploreView from './views/ExploreView.vue'
|
||||||
import BrowseView from './views/BrowseView.vue'
|
import BrowseView from './views/BrowseView.vue'
|
||||||
import ArtistView from './views/ArtistView.vue'
|
import ArtistView from './views/ArtistView.vue'
|
||||||
import SeriesView from './views/SeriesView.vue'
|
import SeriesView from './views/SeriesView.vue'
|
||||||
@@ -20,6 +21,10 @@ const routes = [
|
|||||||
// FC-2: image backbone
|
// FC-2: image backbone
|
||||||
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } },
|
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } },
|
||||||
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } },
|
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } },
|
||||||
|
// Explore: anchor on an image, walk its visual neighbours, close tag-coverage
|
||||||
|
// gaps across the cluster (#94). Optional anchor param — bare /explore (the
|
||||||
|
// nav entry) lands on an empty state that points you at an image.
|
||||||
|
{ path: '/explore/:imageId?', name: 'explore', component: ExploreView, meta: { title: 'Explore' } },
|
||||||
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
|
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
|
||||||
// the three "browse the library by an axis" surfaces. One nav entry; the old
|
// the three "browse the library by an axis" surfaces. One nav entry; the old
|
||||||
// standalone paths redirect into the matching tab (below).
|
// standalone paths redirect into the matching tab (below).
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
|
// Explore (#94): anchor on an image, walk its visual neighbours (pgvector
|
||||||
|
// SigLIP via /api/gallery/similar), keep an in-memory breadcrumb of the walked
|
||||||
|
// path, and surface cluster-consensus tag gaps for the current set. The ROUTE
|
||||||
|
// (`/explore/:imageId`) is the source of truth for the anchor; the view calls
|
||||||
|
// anchorOn() on every param change and the store reconciles the trail.
|
||||||
|
const NEIGHBOR_LIMIT = 24
|
||||||
|
|
||||||
|
export const useExploreStore = defineStore('explore', () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const anchor = ref(null) // /api/gallery/image/<id> payload
|
||||||
|
const neighbors = ref([]) // [{id, thumbnail_url, ...}]
|
||||||
|
const breadcrumb = ref([]) // [{id, thumbnail_url}] walked path
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const inflight = useInflightToken()
|
||||||
|
|
||||||
|
async function anchorOn (id) {
|
||||||
|
const numId = Number(id)
|
||||||
|
if (!Number.isInteger(numId) || numId <= 0) return
|
||||||
|
inflight.cancel()
|
||||||
|
const t = inflight.claim()
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const detail = await api.get(`/api/gallery/image/${numId}`)
|
||||||
|
if (!t.isCurrent()) return
|
||||||
|
anchor.value = detail
|
||||||
|
_reconcileTrail(numId, detail.thumbnail_url)
|
||||||
|
// Videos / not-yet-embedded images have no neighbours — leave the grid
|
||||||
|
// empty and let the view explain why (anchor.has_embedding === false).
|
||||||
|
if (detail.has_embedding) {
|
||||||
|
const body = await api.get('/api/gallery/similar', {
|
||||||
|
params: { similar_to: numId, limit: NEIGHBOR_LIMIT },
|
||||||
|
})
|
||||||
|
if (!t.isCurrent()) return
|
||||||
|
neighbors.value = body.images || []
|
||||||
|
} else {
|
||||||
|
neighbors.value = []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (t.isCurrent()) { error.value = e.message; neighbors.value = [] }
|
||||||
|
} finally {
|
||||||
|
if (t.isCurrent()) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward walk appends; navigating to an id already in the trail (a
|
||||||
|
// breadcrumb click, or a loop back) TRIMS to it — so the route stays the
|
||||||
|
// single source of truth and the crumb bar never grows stale branches.
|
||||||
|
function _reconcileTrail (id, thumbnailUrl) {
|
||||||
|
const idx = breadcrumb.value.findIndex((c) => c.id === id)
|
||||||
|
if (idx >= 0) breadcrumb.value = breadcrumb.value.slice(0, idx + 1)
|
||||||
|
else breadcrumb.value = [...breadcrumb.value, { id, thumbnail_url: thumbnailUrl }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset () {
|
||||||
|
inflight.cancel()
|
||||||
|
anchor.value = null
|
||||||
|
neighbors.value = []
|
||||||
|
breadcrumb.value = []
|
||||||
|
error.value = null
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consensus set = the anchor plus its neighbours.
|
||||||
|
const clusterIds = computed(() => {
|
||||||
|
const ids = anchor.value ? [anchor.value.id] : []
|
||||||
|
return ids.concat(neighbors.value.map((n) => n.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Quick id→thumbnail lookup so the gap panel can render the "missing"
|
||||||
|
// images without another fetch (they're already in the cluster).
|
||||||
|
const thumbById = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
if (anchor.value) map[anchor.value.id] = anchor.value.thumbnail_url
|
||||||
|
for (const n of neighbors.value) map[n.id] = n.thumbnail_url
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
anchor, neighbors, breadcrumb, loading, error,
|
||||||
|
clusterIds, thumbById, NEIGHBOR_LIMIT,
|
||||||
|
anchorOn, reset,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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