<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
/>
<AudioPlayer
v-else-if="viewerType === 'audio'"
:src="item.url"
:title="heading"
/>
<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 { useLightbox } from '@/composables/useLightbox.js'
import AudioPlayer from '@/components/ui/AudioPlayer.vue'
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
}
}
// --- 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
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 ------------------------------------------------------------
The player itself is AudioPlayer.vue: the published-content cards show the
same one, so a track looks and sounds the same in either place. */
</style>