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
350 lines
13 KiB
Vue
350 lines
13 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<div
|
|
class="fc-viewer" role="dialog" aria-modal="true"
|
|
tabindex="-1" ref="rootEl"
|
|
>
|
|
<button class="fc-viewer__close" @click="$emit('close')" aria-label="Close">
|
|
<v-icon>mdi-close</v-icon>
|
|
</button>
|
|
|
|
<!-- C8: keyboard cheatsheet. '?' toggles; the corner hint advertises it. -->
|
|
<button
|
|
class="fc-viewer__help-hint" aria-label="Keyboard shortcuts (press ?)"
|
|
@click="showHelp = !showHelp"
|
|
>?</button>
|
|
<div v-if="showHelp" class="fc-viewer__help" @click.self="showHelp = false">
|
|
<div class="fc-viewer__help-card" role="dialog" aria-label="Keyboard shortcuts">
|
|
<h3>Keyboard shortcuts</h3>
|
|
<dl>
|
|
<div><dt>← / →</dt><dd>Previous / next image</dd></div>
|
|
<div><dt>T or /</dt><dd>Jump to the tag input</dd></div>
|
|
<div><dt>↑ / ↓</dt><dd>Move through tag suggestions</dd></div>
|
|
<div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div>
|
|
<div><dt>Esc</dt><dd>Close a dialog, then the viewer</dd></div>
|
|
<div><dt>?</dt><dd>Toggle this help</dd></div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<v-chip
|
|
v-if="integrityBadge"
|
|
:color="integrityBadge.color"
|
|
class="fc-viewer__integrity"
|
|
size="x-small" variant="flat"
|
|
>{{ integrityBadge.label }}</v-chip>
|
|
|
|
<button
|
|
class="fc-viewer__nav fc-viewer__nav--prev"
|
|
:disabled="!modal.canPrev" @click="modal.goPrev()" aria-label="Previous"
|
|
>
|
|
<v-icon size="32">mdi-chevron-left</v-icon>
|
|
</button>
|
|
<button
|
|
class="fc-viewer__nav fc-viewer__nav--next"
|
|
:disabled="!modal.canNext" @click="modal.goNext()" aria-label="Next"
|
|
>
|
|
<v-icon size="32">mdi-chevron-right</v-icon>
|
|
</button>
|
|
|
|
<!-- Large centered loading spinner (operator-flagged 2026-06-07: the old
|
|
size-36 one sat tiny in the top-left). Dual counter-rotating accent
|
|
rings, centered over the whole modal. pointer-events:none so the
|
|
close/nav controls underneath stay clickable. -->
|
|
<div v-if="modal.loading" class="fc-viewer__loading">
|
|
<div class="fc-viewer__spinner" />
|
|
</div>
|
|
|
|
<div class="fc-viewer__body">
|
|
<div class="fc-viewer__media">
|
|
<template v-if="modal.current">
|
|
<ImageCanvas
|
|
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
|
@close-request="$emit('close')"
|
|
/>
|
|
<VideoCanvas
|
|
v-else :src="modal.current.image_url" :mime="modal.current.mime"
|
|
@close-request="$emit('close')"
|
|
/>
|
|
</template>
|
|
<v-alert v-else-if="modal.error" type="error" variant="tonal">
|
|
{{ modal.error }}
|
|
</v-alert>
|
|
</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;
|
|
collapses silently if empty/slow/failed (see RelatedStrip). -->
|
|
<RelatedStrip />
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
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'
|
|
|
|
const emit = defineEmits(['close'])
|
|
|
|
const modal = useModalStore()
|
|
const rootEl = ref(null)
|
|
const showHelp = ref(false)
|
|
|
|
const isVideo = computed(() =>
|
|
modal.current?.mime && modal.current.mime.startsWith('video/')
|
|
)
|
|
|
|
const integrityBadge = computed(() => {
|
|
const s = modal.current?.integrity_status
|
|
if (s === 'corrupt') return { color: 'error', label: 'Corrupt' }
|
|
if (s === 'failed_verification')
|
|
return { color: 'warning', label: 'Unverified' }
|
|
return null
|
|
})
|
|
|
|
let prevBodyOverflow = null
|
|
|
|
// Document-level keyboard handler. Bound on document (not on the modal
|
|
// root) because <video> elements capture focus when the user interacts
|
|
// with their controls — when video has focus, Escape and arrow keys
|
|
// don't bubble reliably to ancestors. Listening on document side-steps
|
|
// that. Filtered via isTextEntry so tag/comment inputs still get their
|
|
// own keystrokes.
|
|
function onKeyDown(ev) {
|
|
if (ev.key === 'Escape' && showHelp.value) {
|
|
// Close the cheatsheet first; a second Esc closes the modal.
|
|
ev.preventDefault()
|
|
showHelp.value = false
|
|
return
|
|
}
|
|
if (ev.key === 'Escape') {
|
|
// Escape closes the modal even from inside a text input — the universal
|
|
// "get me out of here" expectation; the autofocused tag field would
|
|
// otherwise trap focus (operator-flagged 2026-06-01). EXCEPTION: when the
|
|
// keystroke originates INSIDE an open Vuetify overlay's content (a rename/
|
|
// fandom/alias dialog, or a kebab menu), let that overlay handle its own
|
|
// Esc and don't close the whole modal.
|
|
//
|
|
// #700 re-fix (2026-06-07): the prior guard queried for ANY active overlay
|
|
// anywhere in the DOM and suppressed the close — so a lingering overlay
|
|
// after accepting a suggestion (focus drops to <body>, not into any
|
|
// overlay) wrongly blocked Esc. Keying off the event's origin instead means
|
|
// a stray overlay no longer traps the modal: only an Esc pressed from
|
|
// within overlay content defers to that overlay.
|
|
if (ev.target?.closest?.('.v-overlay__content')) return
|
|
ev.preventDefault()
|
|
emit('close')
|
|
} else if (ev.key === 'ArrowLeft') {
|
|
// Navigate unless the caret is in a non-empty text field (then let it move
|
|
// through the text). An empty tag-entry field still navigates.
|
|
if (!arrowNavAllowed(ev.target)) return
|
|
ev.preventDefault()
|
|
modal.goPrev()
|
|
} else if (ev.key === 'ArrowRight') {
|
|
if (!arrowNavAllowed(ev.target)) return
|
|
ev.preventDefault()
|
|
modal.goNext()
|
|
} else if ((ev.key === '/' || ev.key === 't') && !isTextEntry(ev.target)) {
|
|
// C9 (2026-06-07): jump focus to the tag input from anywhere in the modal.
|
|
const input = document.querySelector('.fc-tag-autocomplete input')
|
|
if (input) { ev.preventDefault(); input.focus() }
|
|
} else if (ev.key === '?' && !isTextEntry(ev.target)) {
|
|
// C8: toggle the keyboard cheatsheet.
|
|
ev.preventDefault()
|
|
showHelp.value = !showHelp.value
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
prevBodyOverflow = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
document.addEventListener('keydown', onKeyDown, true)
|
|
await nextFrame()
|
|
rootEl.value?.focus()
|
|
})
|
|
onUnmounted(() => {
|
|
document.body.style.overflow = prevBodyOverflow
|
|
document.removeEventListener('keydown', onKeyDown, true)
|
|
})
|
|
|
|
watch(() => modal.currentImageId, async () => {
|
|
await nextFrame()
|
|
rootEl.value?.focus()
|
|
})
|
|
|
|
function nextFrame() {
|
|
return new Promise(resolve => requestAnimationFrame(resolve))
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-viewer {
|
|
position: fixed; inset: 0; z-index: 2000;
|
|
/* Single source of truth for the metadata side-panel width — the next
|
|
arrow offsets off it so it never overlaps the panel. */
|
|
--fc-side-w: 320px;
|
|
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
|
|
mid-opacity + blur so the page behind shows through faintly. */
|
|
background: rgba(20, 23, 26, 0.65);
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
outline: none;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
.fc-viewer__close, .fc-viewer__nav {
|
|
position: absolute; top: 50%;
|
|
background: rgba(20, 23, 26, 0.7);
|
|
color: rgb(var(--v-theme-parchment, 232 228 216));
|
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
border-radius: 50%;
|
|
width: 48px; height: 48px;
|
|
display: grid; place-items: center;
|
|
cursor: pointer; z-index: 2;
|
|
transition: background 100ms ease;
|
|
}
|
|
.fc-viewer__close:hover, .fc-viewer__nav:hover:not(:disabled) {
|
|
background: rgb(var(--v-theme-surface));
|
|
}
|
|
.fc-viewer__nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
.fc-viewer__close { top: 16px; right: 16px; transform: none; }
|
|
.fc-viewer__help-hint {
|
|
position: absolute; top: 16px; right: 64px; z-index: 2;
|
|
width: 32px; height: 32px; border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: rgb(var(--v-theme-on-surface));
|
|
font-weight: 700; cursor: pointer; opacity: 0.6;
|
|
}
|
|
.fc-viewer__help-hint:hover { opacity: 1; }
|
|
.fc-viewer__help {
|
|
position: absolute; inset: 0; z-index: 4;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
}
|
|
.fc-viewer__help-card {
|
|
background: rgb(var(--v-theme-surface));
|
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
border-radius: 10px; padding: 20px 24px; min-width: 320px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
}
|
|
.fc-viewer__help-card h3 {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 18px; margin-bottom: 12px;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
.fc-viewer__help-card dl { display: flex; flex-direction: column; gap: 8px; }
|
|
.fc-viewer__help-card dl > div {
|
|
display: flex; gap: 16px; align-items: baseline;
|
|
}
|
|
.fc-viewer__help-card dt {
|
|
flex: 0 0 96px; text-align: right;
|
|
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
|
color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-viewer__help-card dd {
|
|
flex: 1; font-size: 14px;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
.fc-viewer__integrity {
|
|
position: absolute; top: 72px; right: 16px; z-index: 3;
|
|
}
|
|
.fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); }
|
|
/* Sit just inside the image area, clear of the metadata side panel —
|
|
not floating over it (operator-flagged 2026-06-04). */
|
|
.fc-viewer__nav--next {
|
|
right: calc(var(--fc-side-w) + 16px);
|
|
transform: translateY(-50%);
|
|
}
|
|
/* Centered loading overlay — sits over the media band; non-interactive so the
|
|
close/nav controls underneath keep working. */
|
|
.fc-viewer__loading {
|
|
position: absolute; inset: 0; z-index: 2;
|
|
display: flex; align-items: center; justify-content: center;
|
|
pointer-events: none;
|
|
}
|
|
/* Two concentric accent rings counter-rotating, each lit on a different arc —
|
|
reads as one orbiting object. ~108px so it's unmistakably the focal point. */
|
|
.fc-viewer__spinner {
|
|
width: 108px; height: 108px; border-radius: 50%;
|
|
border: 5px solid rgb(var(--v-theme-accent), 0.16);
|
|
border-top-color: rgb(var(--v-theme-accent));
|
|
animation: fc-viewer-spin 0.95s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
|
|
position: relative;
|
|
box-shadow: 0 0 24px rgb(var(--v-theme-accent), 0.18);
|
|
}
|
|
.fc-viewer__spinner::after {
|
|
content: ''; position: absolute; inset: 14px; border-radius: 50%;
|
|
border: 5px solid rgb(var(--v-theme-accent), 0.10);
|
|
border-bottom-color: rgb(var(--v-theme-accent));
|
|
animation: fc-viewer-spin 1.5s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite reverse;
|
|
}
|
|
@keyframes fc-viewer-spin { to { transform: rotate(360deg); } }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.fc-viewer__spinner, .fc-viewer__spinner::after { animation-duration: 3s; }
|
|
}
|
|
|
|
.fc-viewer__body {
|
|
flex: 1; display: flex; min-height: 0;
|
|
}
|
|
.fc-viewer__media {
|
|
/* Let the canvas fill us; the canvas does its own centering. Both
|
|
min-* are needed so this child can shrink inside its flex parents
|
|
(without them, max-height/max-width on the <img> have nothing to
|
|
bound against and the image overflows the viewport). */
|
|
flex: 1; display: flex;
|
|
min-width: 0; min-height: 0;
|
|
}
|
|
.fc-viewer__side {
|
|
width: var(--fc-side-w); flex-shrink: 0;
|
|
background: rgb(var(--v-theme-surface));
|
|
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
|
overflow-y: auto;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
/* Stacked layout (operator-flagged 2026-06-05): pin the image pane and
|
|
its controls at the top while the metadata panel scrolls beneath it.
|
|
Previously the image + a 40vh panel shared one fixed viewport and the
|
|
controls could be pushed out of view; now the body scrolls and the
|
|
media is sticky, so the image + prev/next/close (and the integrity
|
|
badge) stay visible no matter how far the panel is scrolled. */
|
|
.fc-viewer__body {
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
}
|
|
.fc-viewer__media {
|
|
flex: none;
|
|
height: 55vh;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
/* Opaque obsidian so the scrolling panel never bleeds through the
|
|
haze behind the pinned image. */
|
|
background: rgb(20, 23, 26);
|
|
}
|
|
.fc-viewer__side {
|
|
width: 100%;
|
|
max-height: none;
|
|
border-left: none;
|
|
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
/* Re-center the prev/next arrows over the 55vh image band (their base
|
|
top:50% would land on the scrolling panel); next uses the full width
|
|
now that the panel is below, not beside. Close + integrity badge keep
|
|
their top:16px/72px — already within the pinned band. */
|
|
.fc-viewer__nav { top: 27.5vh; }
|
|
.fc-viewer__nav--next { right: 16px; }
|
|
}
|
|
</style>
|