feat(fc2a): assemble ImageViewer modal — Teleported, focused, keyboard-navigable

Teleport mounts the modal under <body> so it can break out of the
gallery container's stacking context. Body overflow is locked while open
so background scroll doesn't compete with pan/zoom.

Keyboard: left/right arrows navigate prev/next (preventDefault so they
don't also scroll the body), Esc closes, Tab is trapped within the
modal by browser default since the modal is focused on mount.

TagPanel renders kind-colored chips with the close (×) affordance for
removal, and hosts TagAutocomplete + FandomPicker for additions. Below
900px the panel drops below the media area instead of beside it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:34:49 -04:00
parent 89fee260a6
commit dcf3af88bd
2 changed files with 208 additions and 7 deletions
+128 -7
View File
@@ -1,9 +1,130 @@
<template>
<v-dialog :model-value="true" max-width="600" @update:model-value="$emit('close')">
<v-card>
<v-card-title>Image viewer</v-card-title>
<v-card-text>Modal viewer lands in Batch 5 (Tasks 1922).</v-card-text>
</v-card>
</v-dialog>
<Teleport to="body">
<div
class="fc-viewer" role="dialog" aria-modal="true"
@keydown.esc="$emit('close')"
@keydown.left.prevent="modal.goPrev()"
@keydown.right.prevent="modal.goNext()"
tabindex="-1" ref="rootEl"
>
<button class="fc-viewer__close" @click="$emit('close')" aria-label="Close">
<v-icon>mdi-close</v-icon>
</button>
<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}`"
/>
<VideoCanvas
v-else :src="modal.current.image_url" :mime="modal.current.mime"
/>
</template>
<v-alert v-else-if="modal.error" type="error" variant="tonal">
{{ modal.error }}
</v-alert>
</div>
<TagPanel v-if="modal.current" />
</div>
</div>
</Teleport>
</template>
<script setup>defineEmits(['close'])</script>
<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'
defineEmits(['close'])
const modal = useModalStore()
const rootEl = ref(null)
const isVideo = computed(() =>
modal.current?.mime && modal.current.mime.startsWith('video/')
)
let prevBodyOverflow = null
onMounted(async () => {
prevBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
await nextFrame()
rootEl.value?.focus()
})
onUnmounted(() => {
document.body.style.overflow = prevBodyOverflow
})
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;
background: rgba(20, 23, 26, 0.96);
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__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 {
flex: 1; display: flex; align-items: center; justify-content: center;
min-width: 0;
}
@media (max-width: 900px) {
.fc-viewer__body { flex-direction: column; }
.fc-tag-panel {
width: 100% !important;
max-height: 40vh;
border-left: none !important;
border-top: 1px solid rgb(var(--v-theme-surface-light));
}
}
</style>