feat(explore): Explore view + tag-gap closing + modal meta/download (#94b–d, #4a/b)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m17s

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:
2026-06-23 02:08:34 -04:00
parent 0ecd1ce4f1
commit 7b712920a4
7 changed files with 577 additions and 5 deletions
+92
View File
@@ -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,
}
})