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:
@@ -1,9 +1,130 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-dialog :model-value="true" max-width="600" @update:model-value="$emit('close')">
|
<Teleport to="body">
|
||||||
<v-card>
|
<div
|
||||||
<v-card-title>Image viewer</v-card-title>
|
class="fc-viewer" role="dialog" aria-modal="true"
|
||||||
<v-card-text>Modal viewer lands in Batch 5 (Tasks 19–22).</v-card-text>
|
@keydown.esc="$emit('close')"
|
||||||
</v-card>
|
@keydown.left.prevent="modal.goPrev()"
|
||||||
</v-dialog>
|
@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>
|
</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>
|
||||||
Reference in New Issue
Block a user