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>
@@ -0,0 +1,80 @@
<template>
<aside class="fc-tag-panel" aria-label="Tags for this image">
<h3 class="fc-tag-panel__title">Tags</h3>
<div class="fc-tag-panel__chips">
<v-chip
v-for="tag in modal.current?.tags || []"
:key="tag.id" size="small" closable
:color="store.colorFor(tag.kind)" variant="tonal"
@click:close="onRemove(tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span>
</v-chip>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
<v-divider class="my-3" />
<TagAutocomplete
@pick-existing="onPickExisting" @pick-new="onPickNew"
/>
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
{{ errorMsg }}
</v-alert>
</aside>
</template>
<script setup>
import { ref } from 'vue'
import { useModalStore } from '../../stores/modal.js'
import { useTagStore } from '../../stores/tags.js'
import TagAutocomplete from './TagAutocomplete.vue'
const modal = useModalStore()
const store = useTagStore()
const errorMsg = ref(null)
const KIND_ICONS = {
general: 'mdi-tag', artist: 'mdi-palette', character: 'mdi-account-circle',
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline'
}
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
async function onRemove(tagId) {
errorMsg.value = null
try { await modal.removeTag(tagId) }
catch (e) { errorMsg.value = e.message }
}
async function onPickExisting(hit) {
errorMsg.value = null
try { await modal.addExistingTag(hit.id) }
catch (e) { errorMsg.value = e.message }
}
async function onPickNew(payload) {
errorMsg.value = null
try { await modal.createAndAdd(payload) }
catch (e) { errorMsg.value = e.message }
}
</script>
<style scoped>
.fc-tag-panel {
width: 320px;
flex-shrink: 0;
padding: 16px;
background: rgb(var(--v-theme-surface));
border-left: 1px solid rgb(var(--v-theme-surface-light));
overflow-y: auto;
}
.fc-tag-panel__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px;
font-weight: 500;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 12px;
}
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
</style>