Files
FabledCurator/frontend/src/components/modal/ImageViewer.vue
T
bvandeusen 1728b43167
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
fix(modal): pin Related to rail bottom, floppy-disk download, drop suggestions cap
Right-rail layout fixes (operator-flagged 2026-06-26 — the prior change wasn't
the intended improvement):

- Pin the Related strip to the BOTTOM of the rail: the side becomes a flex
  column with a scrolling main area (meta + provenance + tags + suggestions)
  and a pinned Related footer (capped at 45% of the rail, scrolls past that).
  Related now stays reachable no matter how long Tags/Suggestions run, and
  self-collapses (no footer space) when there's nothing to show.
- Remove the 320px suggestions scroll cap (3fcc4ae) — it was a workaround "so
  Related stays reachable"; pinning Related is the proper fix, so suggestions
  flow in the single main scroll instead of a nested scrollbar.
- Shrink the Download button to a floppy-disk save icon (mdi-content-save); the
  meta (dimensions/size/type) + save action now sit as a compact top block
  (meta left, icon right). Copy link moves into the adjacent kebab menu.

ImageMetaBar is shared with the Explore center pane, so the compact save
control applies there too (parity). Mobile (<=900px) keeps the single body
scroll — no nested scroll, Related flows at the end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:37:26 -04:00

371 lines
14 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>
<!-- C8: keyboard cheatsheet. '?' toggles; the corner hint advertises it. -->
<button
class="fc-viewer__help-hint" aria-label="Keyboard shortcuts (press ?)"
@click="showHelp = !showHelp"
>?</button>
<div v-if="showHelp" class="fc-viewer__help" @click.self="showHelp = false">
<div class="fc-viewer__help-card" role="dialog" aria-label="Keyboard shortcuts">
<h3>Keyboard shortcuts</h3>
<dl>
<div><dt> / </dt><dd>Previous / next image</dd></div>
<div><dt>T or /</dt><dd>Jump to the tag input</dd></div>
<div><dt> / </dt><dd>Move through tag suggestions</dd></div>
<div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div>
<div><dt>Esc</dt><dd>Close a dialog, then the viewer</dd></div>
<div><dt>?</dt><dd>Toggle this help</dd></div>
</dl>
</div>
</div>
<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>
<!-- Large centered loading spinner (operator-flagged 2026-06-07: the old
size-36 one sat tiny in the top-left). Dual counter-rotating accent
rings, centered over the whole modal. pointer-events:none so the
close/nav controls underneath stay clickable. -->
<div v-if="modal.loading" class="fc-viewer__loading">
<div class="fc-viewer__spinner" />
</div>
<div class="fc-viewer__body">
<div class="fc-viewer__media">
<template v-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">
<!-- Meta + provenance + tags + suggestions scroll together here -->
<div class="fc-viewer__side-main">
<ImageMetaBar />
<ProvenancePanel />
<TagPanel />
</div>
<!-- while Related is PINNED to the bottom of the rail so it stays
reachable no matter how long Tags/Suggestions run (operator-asked
2026-06-26). Non-blocking: fetches its own similar set; collapses
silently (and takes no footer space) if empty/slow/failed. -->
<RelatedStrip class="fc-viewer__related" />
</aside>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useModalStore } from '../../stores/modal.js'
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
import ImageCanvas from './ImageCanvas.vue'
import VideoCanvas from './VideoCanvas.vue'
import ImageMetaBar from './ImageMetaBar.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 showHelp = ref(false)
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' && showHelp.value) {
// Close the cheatsheet first; a second Esc closes the modal.
ev.preventDefault()
showHelp.value = false
return
}
if (ev.key === 'Escape') {
// Escape closes the modal even from inside a text input — the universal
// "get me out of here" expectation; the autofocused tag field would
// otherwise trap focus (operator-flagged 2026-06-01). EXCEPTION: when the
// keystroke originates INSIDE an open Vuetify overlay's content (a rename/
// fandom/alias dialog, or a kebab menu), let that overlay handle its own
// Esc and don't close the whole modal.
//
// #700 re-fix (2026-06-07): the prior guard queried for ANY active overlay
// anywhere in the DOM and suppressed the close — so a lingering overlay
// after accepting a suggestion (focus drops to <body>, not into any
// overlay) wrongly blocked Esc. Keying off the event's origin instead means
// a stray overlay no longer traps the modal: only an Esc pressed from
// within overlay content defers to that overlay.
if (ev.target?.closest?.('.v-overlay__content')) 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()
} else if ((ev.key === '/' || ev.key === 't') && !isTextEntry(ev.target)) {
// C9 (2026-06-07): jump focus to the tag input from anywhere in the modal.
const input = document.querySelector('.fc-tag-autocomplete input')
if (input) { ev.preventDefault(); input.focus() }
} else if (ev.key === '?' && !isTextEntry(ev.target)) {
// C8: toggle the keyboard cheatsheet.
ev.preventDefault()
showHelp.value = !showHelp.value
}
}
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__help-hint {
position: absolute; top: 16px; right: 64px; z-index: 2;
width: 32px; height: 32px; border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgb(var(--v-theme-on-surface));
font-weight: 700; cursor: pointer; opacity: 0.6;
}
.fc-viewer__help-hint:hover { opacity: 1; }
.fc-viewer__help {
position: absolute; inset: 0; z-index: 4;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.fc-viewer__help-card {
background: rgb(var(--v-theme-surface));
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 10px; padding: 20px 24px; min-width: 320px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.fc-viewer__help-card h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; margin-bottom: 12px;
color: rgb(var(--v-theme-on-surface));
}
.fc-viewer__help-card dl { display: flex; flex-direction: column; gap: 8px; }
.fc-viewer__help-card dl > div {
display: flex; gap: 16px; align-items: baseline;
}
.fc-viewer__help-card dt {
flex: 0 0 96px; text-align: right;
font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: rgb(var(--v-theme-accent));
}
.fc-viewer__help-card dd {
flex: 1; font-size: 14px;
color: rgb(var(--v-theme-on-surface));
}
.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%);
}
/* Centered loading overlay — sits over the media band; non-interactive so the
close/nav controls underneath keep working. */
.fc-viewer__loading {
position: absolute; inset: 0; z-index: 2;
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
/* Two concentric accent rings counter-rotating, each lit on a different arc —
reads as one orbiting object. ~108px so it's unmistakably the focal point. */
.fc-viewer__spinner {
width: 108px; height: 108px; border-radius: 50%;
border: 5px solid rgb(var(--v-theme-accent), 0.16);
border-top-color: rgb(var(--v-theme-accent));
animation: fc-viewer-spin 0.95s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
position: relative;
box-shadow: 0 0 24px rgb(var(--v-theme-accent), 0.18);
}
.fc-viewer__spinner::after {
content: ''; position: absolute; inset: 14px; border-radius: 50%;
border: 5px solid rgb(var(--v-theme-accent), 0.10);
border-bottom-color: rgb(var(--v-theme-accent));
animation: fc-viewer-spin 1.5s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite reverse;
}
@keyframes fc-viewer-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.fc-viewer__spinner, .fc-viewer__spinner::after { animation-duration: 3s; }
}
.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));
/* Flex column: a scrolling main area + a pinned Related footer. */
display: flex; flex-direction: column; min-height: 0;
}
.fc-viewer__side-main {
flex: 1 1 auto; min-height: 0; overflow-y: auto;
}
.fc-viewer__related {
/* Pinned to the bottom; capped so a tall strip can't swallow the rail —
it scrolls internally past the cap. RelatedStrip's own border-top draws
the divider; it self-collapses (no space) when there's nothing to show. */
flex: 0 0 auto;
max-height: 45%;
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));
}
/* The whole body scrolls on mobile — don't nest scrolls or pin Related; let
the rail flow naturally (Related lands at the end). */
.fc-viewer__side-main { flex: none; overflow: visible; }
.fc-viewer__related { max-height: none; overflow: visible; }
/* 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>