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,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>