553567738e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
6.0 KiB
Vue
197 lines
6.0 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>
|
|
|
|
<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>
|
|
|
|
<div class="fc-viewer__body">
|
|
<div class="fc-viewer__media">
|
|
<v-progress-circular
|
|
v-if="modal.loading" indeterminate color="accent" size="36"
|
|
/>
|
|
<template v-else-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">
|
|
<ProvenancePanel />
|
|
<TagPanel />
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { useModalStore } from '../../stores/modal.js'
|
|
import ImageCanvas from './ImageCanvas.vue'
|
|
import VideoCanvas from './VideoCanvas.vue'
|
|
import TagPanel from './TagPanel.vue'
|
|
import ProvenancePanel from './ProvenancePanel.vue'
|
|
|
|
const emit = defineEmits(['close'])
|
|
|
|
const modal = useModalStore()
|
|
const rootEl = ref(null)
|
|
|
|
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') {
|
|
if (isTextEntry(ev.target)) return
|
|
ev.preventDefault()
|
|
emit('close')
|
|
} else if (ev.key === 'ArrowLeft') {
|
|
if (isTextEntry(ev.target)) return
|
|
ev.preventDefault()
|
|
modal.goPrev()
|
|
} else if (ev.key === 'ArrowRight') {
|
|
if (isTextEntry(ev.target)) return
|
|
ev.preventDefault()
|
|
modal.goNext()
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
function isTextEntry(el) {
|
|
if (!el) return false
|
|
const tag = el.tagName
|
|
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-viewer {
|
|
position: fixed; inset: 0; z-index: 2000;
|
|
/* 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__integrity {
|
|
position: absolute; top: 72px; right: 16px; z-index: 3;
|
|
}
|
|
.fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); }
|
|
.fc-viewer__nav--next { right: 16px; transform: translateY(-50%); }
|
|
.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: 320px; 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) {
|
|
.fc-viewer__body { flex-direction: column; }
|
|
.fc-viewer__side {
|
|
width: 100%;
|
|
max-height: 40vh;
|
|
border-left: none;
|
|
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
}
|
|
</style>
|