<template>
<Teleport to="body">
<div v-if="item" class="modal a-show" aria-hidden="false">
<div class="modal-backdrop" @click="close" />
<div
ref="dialogRef"
class="modal-dialog lightbox-dialog"
:class="`is-${viewerType}`"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<header class="modal-header">
<h4 class="modal-title">{{ heading }}</h4>
<div class="lightbox-tools">
<a
class="btn-icon"
:href="item.url"
:title="t('common.download')"
:aria-label="t('common.download')"
download
>
<i class="ph ph-download-simple"></i>
</a>
<button class="btn-icon modal-close" type="button" :aria-label="t('common.close')" @click="close">
<i class="ph ph-x"></i>
</button>
</div>
</header>
<div class="modal-panel lightbox-panel">
<div class="modal-body">
<img
v-if="viewerType === 'image' && !imgError"
:src="item.url"
:alt="heading"
class="lightbox-img"
@error="imgError = true"
/>
<div v-else-if="viewerType === 'image'" class="lightbox-error">
<i class="ph ph-image-broken"></i>
<span>{{ t('ui.loadImageFailed') }}</span>
</div>
<video
v-else-if="viewerType === 'video'"
:src="item.url"
class="lightbox-video"
controls
playsinline
/>
<div v-else-if="viewerType === 'audio'" class="lightbox-audio">
<div class="lightbox-audio-art">
<i class="ph ph-waveform"></i>
</div>
<div class="lightbox-audio-title">{{ heading }}</div>
<div v-if="audioError" class="lightbox-audio-error">
{{ t('artifacts.audioFailed') }}
</div>
<div class="lightbox-audio-controls">
<GnIconButton
:icon="playing ? 'ph ph-pause' : 'ph ph-play'"
:label="playing ? t('artifacts.pause') : t('artifacts.play')"
@click="togglePlay"
/>
<span class="lightbox-audio-time">{{ formatTime(currentTime) }}</span>
<GnRange
class="lightbox-audio-seek"
:model-value="currentTime"
:min="0"
:max="duration || 0"
:step="0.1"
:aria-label="t('artifacts.seek')"
@update:model-value="seekTo"
/>
<span class="lightbox-audio-time">{{ formatTime(duration) }}</span>
<GnIconButton
:icon="muted ? 'ph ph-speaker-simple-slash' : 'ph ph-speaker-simple-high'"
:label="muted ? t('artifacts.unmute') : t('artifacts.mute')"
@click="toggleMute"
/>
<div class="lightbox-audio-volume">
<GnRange
:model-value="muted ? 0 : volume"
:min="0"
:max="1"
:step="0.05"
:aria-label="t('artifacts.volume')"
@update:model-value="setVolume"
/>
</div>
</div>
<audio
ref="audioEl"
:src="item.url"
class="lightbox-audio-el"
preload="metadata"
@loadedmetadata="onMetadata"
@timeupdate="onTimeUpdate"
@play="playing = true"
@pause="playing = false"
@ended="playing = false"
@volumechange="onVolumeChange"
@error="audioError = true"
/>
</div>
<div v-else-if="viewerType === 'text'" class="lightbox-text">
<div v-if="textLoading" class="lightbox-state">
<span class="spinner"></span>
<span>{{ t('artifacts.loadingPreview') }}</span>
</div>
<pre v-else-if="textError" class="lightbox-state lightbox-state-error">{{ textError }}</pre>
<template v-else>
<pre class="lightbox-code"><code class="hljs" :class="`language-${textLanguage}`" v-html="highlightedText"></code></pre>
<div v-if="textTruncated" class="lightbox-note">
{{ t('artifacts.textPreviewLimited', { size: textPreviewLimitLabel }) }}
<a :href="item.url" download>{{ t('common.download') }}</a>
</div>
</template>
</div>
<!-- Same viewer pages the inline card uses: an SVG/HTML/PDF/STL
file needs its own document, and the model viewer picks the
three.js loader from the file's extension. -->
<iframe
v-else
:src="frameUrl"
class="lightbox-frame"
sandbox="allow-scripts allow-same-origin"
/>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { GnIconButton, GnRange } from 'gnexus-ui-kit/vue'
import { useLightbox } from '@/composables/useLightbox.js'
import { viewerContentUrl } from '@/utils/contentLinks.js'
import { viewerNameFor } from '@/utils/contentTypes.js'
import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.js'
import { t } from '@/i18n/index.js'
const TEXT_PREVIEW_LIMIT = 1_000_000
const { item, close } = useLightbox()
const dialogRef = ref(null)
const imgError = ref(false)
// A bare image src arrives as type 'image', so the markdown path lands on the
// <img> branch; every other type gets its own branch or a viewer page.
const viewerType = computed(() => {
const type = item.value?.type || 'image'
if (['image', 'video', 'audio', 'text'].includes(type)) return type
return 'frame'
})
const frameUrl = computed(() => {
if (!item.value) return ''
return viewerContentUrl(viewerNameFor(item.value.type), item.value.url)
})
const heading = computed(() => {
if (!item.value) return ''
if (item.value.title) return item.value.title
return item.value.type === 'image' ? t('ui.photo') : t('ui.preview')
})
// --- Text ---------------------------------------------------------------
const textContent = ref('')
const textLoading = ref(false)
const textError = ref('')
const textTruncated = ref(false)
let textRequest = 0
const textLanguage = computed(() => sourceLanguage({ url: item.value?.url || '' }))
const highlightedText = computed(() => highlightSource(textContent.value, textLanguage.value))
const textPreviewLimitLabel = `${Math.round(TEXT_PREVIEW_LIMIT / 1024)} KB`
async function loadText(url) {
const request = ++textRequest
textLoading.value = true
textError.value = ''
textContent.value = ''
textTruncated.value = false
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const raw = await res.text()
if (request !== textRequest) return
textTruncated.value = raw.length > TEXT_PREVIEW_LIMIT
textContent.value = textTruncated.value ? raw.slice(0, TEXT_PREVIEW_LIMIT) : raw
} catch (err) {
if (request !== textRequest) return
textError.value = t('artifacts.sourceFailed', { error: err?.message || err })
} finally {
if (request === textRequest) textLoading.value = false
}
}
// --- Audio --------------------------------------------------------------
const audioEl = ref(null)
const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(1)
const muted = ref(false)
const audioError = ref(false)
function resetAudio() {
playing.value = false
currentTime.value = 0
duration.value = 0
volume.value = 1
muted.value = false
audioError.value = false
}
async function togglePlay() {
const el = audioEl.value
if (!el) return
if (el.paused) {
try {
await el.play()
} catch {
audioError.value = true
}
} else {
el.pause()
}
}
function onMetadata() {
const el = audioEl.value
if (!el) return
// A metadata-less stream reports Infinity/NaN; the seek bar must stay sane.
duration.value = Number.isFinite(el.duration) ? el.duration : 0
}
function onTimeUpdate() {
if (audioEl.value) currentTime.value = audioEl.value.currentTime
}
function seekTo(value) {
const el = audioEl.value
if (!el) return
const target = Math.min(Number(value) || 0, duration.value || 0)
el.currentTime = target
currentTime.value = target
}
function setVolume(value) {
const el = audioEl.value
if (!el) return
const next = Math.max(0, Math.min(1, Number(value) || 0))
el.muted = false
el.volume = next
volume.value = next
muted.value = next === 0
}
function toggleMute() {
const el = audioEl.value
if (!el) return
el.muted = !el.muted
}
function onVolumeChange() {
const el = audioEl.value
if (!el) return
muted.value = el.muted || el.volume === 0
if (!el.muted) volume.value = el.volume
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const total = Math.floor(seconds)
const mins = Math.floor(total / 60)
const secs = total % 60
return `${mins}:${String(secs).padStart(2, '0')}`
}
// --- Lifecycle ----------------------------------------------------------
function onKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault()
close()
}
}
watch(
item,
(val) => {
document.body.style.overflow = val ? 'hidden' : ''
imgError.value = false
textRequest++
textLoading.value = false
textError.value = ''
textContent.value = ''
textTruncated.value = false
resetAudio()
if (val) {
window.addEventListener('keydown', onKeydown)
nextTick(() => dialogRef.value?.focus?.())
if (val.type === 'text') loadText(val.url)
} else {
window.removeEventListener('keydown', onKeydown)
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
</script>
<style>
/* Standard kit dialog (fixed max-width, centered on screen); the photo is
centered inside it, so partial-proportion images never sit in a corner
of empty space. Documents and video get the whole screen instead — that is
the point of the button that opened them. */
.lightbox-dialog {
max-width: 90vw;
min-width: 280px;
}
.lightbox-dialog.is-frame {
width: 96vw;
max-width: 96vw;
}
.lightbox-dialog.is-text {
width: min(1100px, 94vw);
max-width: min(1100px, 94vw);
}
.lightbox-dialog.is-audio {
width: min(640px, 94vw);
max-width: min(640px, 94vw);
}
.lightbox-tools {
display: flex;
align-items: center;
gap: 4px;
}
/* Kit's `.modal .modal-dialog .modal-panel` sets min-height: 200px at (0,3,0)
— match it to center the photo vertically when it is shorter. */
body .modal .lightbox-dialog .lightbox-panel {
justify-content: center;
cursor: default;
}
body .modal .lightbox-panel .modal-body {
max-height: 90vh;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
body .modal .lightbox-dialog.is-frame .lightbox-panel .modal-body,
body .modal .lightbox-dialog.is-video .lightbox-panel .modal-body {
height: 80vh;
max-height: 80vh;
}
/* A file sizes the dialog instead of a fixed 80vh box: a twenty-line script
in a screen-tall dialog is a wall of empty background under the last line.
Long files stop at 80vh and scroll inside the text block. */
body .modal .lightbox-dialog.is-text .lightbox-panel .modal-body {
align-items: stretch;
justify-content: flex-start;
height: auto;
max-height: none;
}
/* Natural size within the viewport — no crop (object-fit: cover would
cut edges) and no distortion; the panel shrink-wraps the result. */
.lightbox-img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 90vh;
}
.lightbox-video {
display: block;
width: 100%;
height: 100%;
background: #000;
}
.lightbox-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #1a1a2e;
}
.lightbox-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: var(--gn-color-text-dark);
font-size: 13px;
}
.lightbox-error i {
font-size: 32px;
}
/* --- Text ------------------------------------------------------------- */
.lightbox-text {
width: 100%;
height: auto;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: auto;
background: var(--gn-color-black);
}
.lightbox-code {
margin: 0;
padding: 16px;
white-space: pre;
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
line-height: 1.6;
color: var(--gn-color-text-light);
}
.lightbox-note {
position: sticky;
bottom: 0;
padding: 10px 16px;
border-top: 1px solid var(--gn-border-color-muted);
background: var(--gn-surface-panel);
color: var(--gn-color-text-dark);
font-size: 12px;
}
.lightbox-note a {
color: var(--gn-color-accent);
margin-left: 6px;
}
.lightbox-state {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
margin: 0;
color: var(--gn-color-text-dark);
font-size: 13px;
}
.lightbox-state-error {
color: var(--gn-color-error);
}
/* --- Audio ------------------------------------------------------------ */
.lightbox-audio {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 28px 24px;
background: var(--gn-surface-panel);
}
.lightbox-audio-art {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border-radius: 50%;
background: var(--gn-color-black);
color: var(--gn-color-accent);
font-size: 40px;
}
.lightbox-audio-title {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--gn-color-text-light);
font-size: 14px;
font-weight: 500;
}
.lightbox-audio-error {
color: var(--gn-color-error);
font-size: 12px;
}
.lightbox-audio-controls {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
}
.lightbox-audio-el {
display: none;
}
.lightbox-audio-time {
flex: none;
color: var(--gn-color-text-dark);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* The kit range renders a label wrapper; inside a media row it needs to
behave as a bare track. */
.lightbox-audio-controls .range {
flex: 1;
min-width: 0;
margin: 0;
}
.lightbox-audio-controls .range .label {
margin: 0;
}
.lightbox-audio-seek {
flex: 1;
}
.lightbox-audio-volume {
flex: none;
width: 90px;
display: flex;
}
.lightbox-audio-volume .range {
width: 100%;
margin: 0;
}
@media (max-width: 600px) {
.lightbox-audio-volume {
display: none;
}
.lightbox-audio {
padding: 20px 16px;
}
}
</style>