Files
FabledCurator/frontend/src/components/modal/ImageViewer.vue
T

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>