#712 — fandom picker showed no existing fandoms: loadFandoms enumerated via /tags/autocomplete with q=' ', which the backend strips to empty → returns []. Switch to the cursor-paged /tags/directory?kind=fandom (loop all pages); drop the load-once guard so the dialog reflects fandoms created elsewhere. #711 — modal tag-chip kebab never opened: the kebab + menu were nested INSIDE the v-chip, which swallowed the click / mis-anchored the teleported menu. Un-nest it as a sibling v-btn using the standard v-menu activator slot (Vuetify wires the click and stacks the overlay above the modal natively). Removes the openTagId workaround. #700 — ESC didn't close the modal after accepting a suggested tag: the guard suppressed close while ANY .v-overlay--active existed, which includes tooltips — a lingering tooltip blocked the close. Exclude .v-tooltip from the guard so only real interactive overlays (menus/dialogs) keep ESC from closing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
8.6 KiB
Vue
244 lines
8.6 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 />
|
|
<!-- Non-blocking: fetches its own similar set after the modal is up;
|
|
collapses silently if empty/slow/failed (see RelatedStrip). -->
|
|
<RelatedStrip />
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { useModalStore } from '../../stores/modal.js'
|
|
import { arrowNavAllowed } from '../../utils/textEntry.js'
|
|
import ImageCanvas from './ImageCanvas.vue'
|
|
import VideoCanvas from './VideoCanvas.vue'
|
|
import TagPanel from './TagPanel.vue'
|
|
import ProvenancePanel from './ProvenancePanel.vue'
|
|
import RelatedStrip from './RelatedStrip.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') {
|
|
// Escape closes the modal even from inside a text input — that's
|
|
// the universal "get me out of here" expectation, and the
|
|
// autofocused tag-entry field would otherwise trap focus with no
|
|
// visible escape (operator-flagged 2026-06-01). EXCEPTION: when a
|
|
// nested Vuetify overlay is open (v-menu autocomplete dropdown,
|
|
// FandomPicker v-dialog, per-suggestion 3-dot menu), let that
|
|
// overlay's own Esc handling fire instead of closing the whole
|
|
// modal mid-interaction. Vuetify marks open overlays with
|
|
// `.v-overlay--active`.
|
|
// EXCLUDE tooltips (`.v-tooltip`): they're also `.v-overlay--active` while
|
|
// shown, so one lingering after a hover/click (e.g. just after accepting a
|
|
// suggested tag) wrongly suppressed the close (#700). Only real interactive
|
|
// overlays (menus/dialogs) should keep ESC from closing the modal.
|
|
if (document.querySelector('.v-overlay--active:not(.v-tooltip)')) return
|
|
ev.preventDefault()
|
|
emit('close')
|
|
} else if (ev.key === 'ArrowLeft') {
|
|
// Navigate unless the caret is in a non-empty text field (then let it move
|
|
// through the text). An empty tag-entry field still navigates.
|
|
if (!arrowNavAllowed(ev.target)) return
|
|
ev.preventDefault()
|
|
modal.goPrev()
|
|
} else if (ev.key === 'ArrowRight') {
|
|
if (!arrowNavAllowed(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))
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-viewer {
|
|
position: fixed; inset: 0; z-index: 2000;
|
|
/* Single source of truth for the metadata side-panel width — the next
|
|
arrow offsets off it so it never overlaps the panel. */
|
|
--fc-side-w: 320px;
|
|
/* 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%); }
|
|
/* Sit just inside the image area, clear of the metadata side panel —
|
|
not floating over it (operator-flagged 2026-06-04). */
|
|
.fc-viewer__nav--next {
|
|
right: calc(var(--fc-side-w) + 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: var(--fc-side-w); 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) {
|
|
/* Stacked layout (operator-flagged 2026-06-05): pin the image pane and
|
|
its controls at the top while the metadata panel scrolls beneath it.
|
|
Previously the image + a 40vh panel shared one fixed viewport and the
|
|
controls could be pushed out of view; now the body scrolls and the
|
|
media is sticky, so the image + prev/next/close (and the integrity
|
|
badge) stay visible no matter how far the panel is scrolled. */
|
|
.fc-viewer__body {
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
}
|
|
.fc-viewer__media {
|
|
flex: none;
|
|
height: 55vh;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
/* Opaque obsidian so the scrolling panel never bleeds through the
|
|
haze behind the pinned image. */
|
|
background: rgb(20, 23, 26);
|
|
}
|
|
.fc-viewer__side {
|
|
width: 100%;
|
|
max-height: none;
|
|
border-left: none;
|
|
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
/* Re-center the prev/next arrows over the 55vh image band (their base
|
|
top:50% would land on the scrolling panel); next uses the full width
|
|
now that the panel is below, not beside. Close + integrity badge keep
|
|
their top:16px/72px — already within the pinned band. */
|
|
.fc-viewer__nav { top: 27.5vh; }
|
|
.fc-viewer__nav--next { right: 16px; }
|
|
}
|
|
</style>
|