1728b43167
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>
371 lines
14 KiB
Vue
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>
|