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,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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user