7b712920a4
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
97 lines
3.3 KiB
Vue
97 lines
3.3 KiB
Vue
<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>
|