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
@@ -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>
<aside v-if="modal.current" class="fc-viewer__side">
<ImageMetaBar />
<ProvenancePanel />
<TagPanel />
<!-- 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 ImageCanvas from './ImageCanvas.vue'
import VideoCanvas from './VideoCanvas.vue'
import ImageMetaBar from './ImageMetaBar.vue'
import TagPanel from './TagPanel.vue'
import ProvenancePanel from './ProvenancePanel.vue'
import RelatedStrip from './RelatedStrip.vue'
+21 -5
View File
@@ -5,11 +5,18 @@
<div v-if="show" class="fc-related">
<div class="fc-related__head">
<span class="fc-related__title">Related</span>
<v-btn
size="x-small" variant="text" color="accent"
:disabled="loading || !results.length"
@click="seeAll"
>See all similar</v-btn>
<div class="fc-related__actions">
<v-btn
size="x-small" variant="text" color="accent"
prepend-icon="mdi-compass-outline"
@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 class="fc-related__row">
<template v-if="loading">
@@ -94,6 +101,14 @@ function seeAll() {
modal.close()
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>
<style scoped>
@@ -105,6 +120,7 @@ function seeAll() {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.fc-related__actions { display: flex; align-items: center; gap: 2px; }
.fc-related__title {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant));