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
            />
            <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>