Newer
Older
navi-1 / webclient / src / components / ui / ContentLightbox.vue
<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>