Newer
Older
navi-1 / webclient / src / components / messages / ContentCard.vue
<template>
  <details
    v-if="!isError"
    ref="detailsEl"
    class="content-card"
    :class="contentTypeClass"
    :open="isOpen"
    @toggle="syncOpen"
  >
    <summary @click.prevent="detailsToggle">
      <i class="content-icon" :class="icon" />
      <span class="content-title">{{ title }}</span>
      <GnBadge :variant="badgeVariant">{{ contentType }}</GnBadge>
      <span class="content-actions" @click.stop>
        <a :href="previewUrl" target="_blank" rel="noopener noreferrer" :title="t('artifacts.openPreview')" class="btn-icon btn-icon-sm">
          <i class="ph ph-arrow-square-out"></i>
        </a>
        <a :href="downloadUrl" target="_blank" rel="noopener noreferrer" :title="t('common.download')" class="btn-icon btn-icon-sm">
          <i class="ph ph-download-simple"></i>
        </a>
        <button
          v-if="canFullscreen"
          type="button"
          class="btn-icon btn-icon-sm"
          :title="t('artifacts.openFullscreen')"
          @click="openFullscreen"
        >
          <i class="ph ph-arrows-out"></i>
        </button>
        <button
          v-if="hasSource"
          type="button"
          class="btn-icon btn-icon-sm"
          :title="t(showSource ? 'artifacts.showPreview' : 'artifacts.showSource')"
          @click="toggleSource"
        >
          <i :class="showSource ? 'ph ph-eye' : 'ph ph-code'"></i>
        </button>
        <button type="button" :title="t('artifacts.copyFileLink')" class="btn-icon btn-icon-sm" @click="copyLink">
          <i :class="copyIcon"></i>
        </button>
      </span>
      <i class="ph ph-caret-down content-chevron"></i>
    </summary>

    <!-- body mounts on first open (iframes load lazily) and then stays in
         the DOM: the collapse animation measures the content to know how far
         to shrink, so a v-if/v-show teardown at toggle time is what made the
         close look abrupt (height snapped to 0 in a single frame). While
         closed, details' content-visibility hides the body again. -->
    <div v-if="bodyMounted" class="content-body">
      <div v-if="showSource" class="content-source">
        <div v-if="sourceLoading" class="content-source-state">
          <span class="spinner"></span>
          <span>{{ t('artifacts.loadingSource') }}</span>
        </div>
        <pre v-else-if="sourceError" class="content-source-error">{{ sourceError }}</pre>
        <pre v-else class="content-source-code"><code class="hljs" :class="`language-${sourceLanguageName}`" v-html="highlightedSource"></code></pre>
      </div>

      <!-- 3D: STL and glTF/GLB share the model viewer, which picks the
           three.js loader from the file's own extension -->
      <iframe
        v-else-if="isModelType"
        :src="viewerUrl('model')"
        class="content-iframe"
        sandbox="allow-scripts allow-same-origin"
        loading="lazy"
      />

      <!-- HTML -->
      <iframe
        v-else-if="detectedContentType === 'html'"
        :src="viewerUrl('html')"
        class="content-iframe"
        sandbox="allow-scripts allow-same-origin"
        loading="lazy"
      />

      <!-- SVG -->
      <iframe
        v-else-if="detectedContentType === 'svg'"
        :src="viewerUrl('svg')"
        class="content-iframe"
        sandbox="allow-scripts allow-same-origin"
        loading="lazy"
      />

      <!-- PDF -->
      <iframe
        v-else-if="detectedContentType === 'pdf'"
        :src="viewerUrl('pdf')"
        class="content-iframe"
        loading="lazy"
      />

      <!-- Image -->
      <div v-else-if="detectedContentType === 'image'" class="content-image-wrap">
        <img
          :src="fileUrl"
          class="content-image"
          :alt="title"
          loading="lazy"
          @load="imageLoaded = true"
          @click="openLightbox"
        />
        <div v-if="!imageLoaded" class="content-image-skeleton">
          <i class="ph ph-spinner ph-spin content-image-spinner"></i>
        </div>
      </div>

      <!-- Video -->
      <video
        v-else-if="detectedContentType === 'video'"
        :src="fileUrl"
        class="content-video"
        controls
        playsinline
      />

      <!-- Audio: the same player the full-screen viewer uses -->
      <div v-else-if="detectedContentType === 'audio'" class="content-audio-wrap">
        <AudioPlayer :src="fileUrl" :title="title" compact />
      </div>

      <!-- Text and source: read in place instead of a download link -->
      <div v-else-if="detectedContentType === 'text'" class="content-text">
        <div v-if="textLoading" class="content-source-state">
          <span class="spinner"></span>
          <span>{{ t('artifacts.loadingPreview') }}</span>
        </div>
        <pre v-else-if="textError" class="content-source-error">{{ textError }}</pre>
        <template v-else>
          <pre class="content-source-code"><code class="hljs" :class="`language-${sourceLanguageName}`" v-html="highlightedText"></code></pre>
          <div v-if="textTruncated" class="content-text-note">
            {{ t('artifacts.textPreviewLimited', { size: textPreviewLimitLabel }) }}
          </div>
        </template>
      </div>

      <!-- Unknown -->
      <div v-else class="content-unknown">
        <i class="unknown-icon" :class="icon" />
        <div class="unknown-name">{{ title }}</div>
        <a :href="fileUrl" target="_blank" rel="noopener noreferrer" class="btn btn-small btn-secondary unknown-open">
          {{ t('messages.openFile') }}
        </a>
      </div>
    </div>
  </details>

  <!-- Error fallback: render like a ToolCard so the user sees the failure clearly -->
  <details
    v-else
    ref="detailsEl"
    class="tool-card is-error"
    :open="isOpen"
    @toggle="syncOpen"
  >
    <summary @click.prevent="detailsToggle">
      <span class="tool-status-icon">
        <i class="ph ph-x-circle"></i>
      </span>
      <span class="tool-emoji">📎</span>
      <span class="tool-name">content_publish</span>
      <i class="ph ph-caret-down tool-chevron"></i>
    </summary>
    <div class="tool-card-body">
      <div class="tool-section">
        <div class="tool-section-label">{{ t('messages.arguments') }}</div>
        <pre class="tool-code">{{ formatJson(tool.args) }}</pre>
      </div>
      <div class="tool-section">
        <div class="tool-section-label">{{ t('common.result') }}</div>
        <pre class="tool-code">{{ tool.result }}</pre>
      </div>
    </div>
  </details>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { useLightbox } from '@/composables/useLightbox.js'
import AudioPlayer from '@/components/ui/AudioPlayer.vue'
import { absoluteContentUrl, contentFileUrl, viewerContentUrl } from '@/utils/contentLinks.js'
import {
  BADGE_VARIANTS,
  CONTENT_TYPE_ICONS,
  contentTypeFromName,
  isFrameType,
  isFullscreenType,
  normalizeContentType,
  viewerNameFor,
} from '@/utils/contentTypes.js'
import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.js'
import { detailsToggle } from '@/utils/detailsAnimation.js'
import { t } from '@/i18n/index.js'

const props = defineProps({
  tool: { type: Object, required: true },
  defaultOpen: { type: Boolean, default: false },
  collapseToken: { type: String, default: '' }
})

const detailsEl = ref(null)
const isOpen = ref(props.defaultOpen)
// The body (iframes/video) is mounted on first open, then kept alive so the
// collapse animation has real content to measure and shrink
const bodyMounted = ref(props.defaultOpen)
const linkCopied = ref(false)
const showSource = ref(false)
const sourceText = ref('')
const sourceLoading = ref(false)
const sourceError = ref('')
const imageLoaded = ref(false)
const textContent = ref('')
const textLoading = ref(false)
const textError = ref('')
const textTruncated = ref(false)
const { open, openContent } = useLightbox()

// A text file is fetched to be read, not streamed: cap what goes into the DOM
// and say that the rest is behind the download link.
const TEXT_PREVIEW_LIMIT = 1_000_000
const textPreviewLimitLabel = computed(() => `${Math.round(TEXT_PREVIEW_LIMIT / 1024)} KB`)

watch(
  () => [props.defaultOpen, props.collapseToken],
  ([value]) => {
    isOpen.value = value
    if (value) bodyMounted.value = true
  },
)

const isError = computed(() => {
  if (props.tool.success === false) return true
  if (!props.tool.metadata || !props.tool.metadata.url) {
    const result = String(props.tool.result ?? '')
    return !result.match(/URL: (.+)/)
  }
  return false
})

// content_publish tool stores metadata in the tool card
const metadata = computed(() => {
  // metadata is set by onToolCall from WS event (live stream)
  if (props.tool.metadata) {
    return props.tool.metadata
  }
  // Fallback: parse from persisted text output (used on page reload / reconnect)
  const result = String(props.tool.result ?? '')
  const urlMatch = result.match(/URL: (.+)/)
  if (urlMatch) {
    const typeMatch = result.match(/Published: .+ \(([^)]+)\)/)
    const titleMatch = result.match(/Published: (.+) \(/)
    return {
      url: urlMatch[1].trim(),
      content_type: typeMatch ? typeMatch[1].trim() : 'unknown',
      title: titleMatch ? titleMatch[1].trim() : t('messages.untitledContent'),
    }
  }
  return {}
})

const url = computed(() => metadata.value.url || '')
const fileUrl = computed(() => contentFileUrl(url.value))
// The image src can change (switched file / live metadata) — drop the
// loaded flag so the skeleton shows again for the new image.
watch(
  () => fileUrl.value,
  () => { imageLoaded.value = false },
)
const contentType = computed(() => metadata.value.content_type || 'unknown')
const title = computed(() => metadata.value.title || metadata.value.filename || t('messages.untitledContent'))

// The published type when the backend detected one, otherwise the name says.
// One table for both cards now (utils/contentTypes.js) — the two local maps
// this replaced had already drifted apart.
const detectedContentType = computed(() =>
  normalizeContentType(contentType.value) || contentTypeFromName(metadata.value.filename || url.value),
)
const isModelType = computed(() => ['stl', 'model'].includes(detectedContentType.value))
const icon = computed(() => CONTENT_TYPE_ICONS[detectedContentType.value] || CONTENT_TYPE_ICONS.unknown)
const badgeVariant = computed(() => BADGE_VARIANTS[detectedContentType.value] || 'primary')
const contentTypeClass = computed(() => `is-${detectedContentType.value}`)
const canFullscreen = computed(() => isFullscreenType(detectedContentType.value))
const previewUrl = computed(() => {
  const type = detectedContentType.value
  return isFrameType(type) ? viewerUrl(viewerNameFor(type)) : absoluteContentUrl(fileUrl.value)
})
const shareUrl = computed(() => (isModelType.value ? previewUrl.value : absoluteContentUrl(fileUrl.value)))
const sourceUrl = computed(() => {
  if (['html', 'svg'].includes(detectedContentType.value)) return fileUrl.value
  if (isModelType.value && metadata.value.source_url) return contentFileUrl(metadata.value.source_url)
  return ''
})
const hasSource = computed(() => Boolean(sourceUrl.value))
const sourceLanguageName = computed(() => sourceLanguage({
  ...metadata.value,
  content_type: detectedContentType.value,
  filename: metadata.value.source_filename || metadata.value.filename || url.value,
}))
const highlightedSource = computed(() => highlightSource(sourceText.value, sourceLanguageName.value))
const highlightedText = computed(() => highlightSource(textContent.value, sourceLanguageName.value))
const copyIcon = computed(() => linkCopied.value ? 'ph ph-check' : 'ph ph-copy')
const downloadUrl = computed(() => {
  const raw = contentFileUrl(metadata.value.download_url || url.value)
  if (metadata.value.download_url || !raw) return raw
  return `${raw}${raw.includes('?') ? '&' : '?'}download=1`
})

function viewerUrl(viewerType) {
  return viewerContentUrl(viewerType, fileUrl.value)
}

function openLightbox() {
  if (detectedContentType.value === 'image') open(fileUrl.value)
}

function openFullscreen() {
  if (!canFullscreen.value) return
  openContent({
    url: absoluteContentUrl(fileUrl.value),
    type: detectedContentType.value,
    title: title.value,
  })
}

async function loadText() {
  if (!fileUrl.value || textLoading.value || textContent.value) return
  textLoading.value = true
  textError.value = ''
  try {
    const res = await fetch(fileUrl.value)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    const raw = await res.text()
    textTruncated.value = raw.length > TEXT_PREVIEW_LIMIT
    textContent.value = textTruncated.value ? raw.slice(0, TEXT_PREVIEW_LIMIT) : raw
  } catch (err) {
    textError.value = t('artifacts.sourceFailed', { error: err?.message || err })
  } finally {
    textLoading.value = false
  }
}

// Fetch the text only when the card is opened — the body mounts lazily and a
// closed card should not download a megabyte nobody is looking at.
watch([isOpen, detectedContentType], ([open, type]) => {
  if (open && type === 'text') loadText()
}, { immediate: true })

// The file behind the card can change (live metadata) — read it again.
watch(fileUrl, () => {
  textContent.value = ''
  textError.value = ''
  textTruncated.value = false
})

function syncOpen(event) {
  isOpen.value = event.currentTarget.open
  if (isOpen.value) bodyMounted.value = true
}

async function toggleSource() {
  showSource.value = !showSource.value
  if (showSource.value) await loadSource()
}

async function loadSource() {
  if (!sourceUrl.value || sourceText.value || sourceLoading.value) return
  sourceLoading.value = true
  sourceError.value = ''
  try {
    const res = await fetch(sourceUrl.value)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    sourceText.value = await res.text()
  } catch (err) {
    sourceError.value = t('artifacts.sourceFailed', { error: err?.message || err })
  } finally {
    sourceLoading.value = false
  }
}

async function copyLink() {
  try {
    await navigator.clipboard.writeText(shareUrl.value)
    linkCopied.value = true
    setTimeout(() => { linkCopied.value = false }, 1500)
  } catch {
    // Clipboard can fail on non-secure origins; ignore.
  }
}

function formatJson(val) {
  if (typeof val === 'string') {
    try { return JSON.stringify(JSON.parse(val), null, 2) } catch { return val }
  }
  return JSON.stringify(val, null, 2)
}
</script>

<style scoped lang="scss">
@use 'kit-deps' as *;

.content-card {
  margin: 8px 0;
  background: var(--gn-surface-panel);
  border: 1px solid var(--gn-border-color-muted);
  /* Kit card radius */
  border-radius: $border-radius-md;
  overflow: hidden;
}

.content-card summary {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  cursor: pointer;
  user-select: none;
  list-style: none;
}

.content-card summary::-webkit-details-marker {
  display: none;
}

.content-icon {
  font-size: 20px;
  line-height: 1;
  flex-shrink: 0;
  color: var(--gn-color-text-medium);
}

.content-title {
  flex: 1;
  font-size: 0.9em;
  font-weight: 500;
  color: var(--gn-color-text-light);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The kit badge already owns its look; only keep it from flex-shrinking */
:deep(.badge) {
  flex-shrink: 0;
}

.content-actions {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}

/* Actions are plain kit btn-icons now — only strip their hover border glow
   so the row stays quiet */
.content-actions .btn-icon {
  width: 26px;
  height: 26px;
  font-size: 18px;
  border-color: transparent;
}

.content-chevron {
  transition: transform 0.2s;
  color: var(--gn-color-text-dark);
}

.content-card[open] .content-chevron {
  transform: rotate(180deg);
}

.content-body {
  border-top: 1px solid var(--gn-border-color-muted);
  overflow: auto;
}

.content-iframe {
  width: 100%;
  height: 300px;
  border: none;
  display: block;
}

@media (min-width: 768px) {
  .content-iframe {
    height: 500px;
  }
}

.content-image-wrap {
  position: relative;
  /* An unrest loaded <img> collapses to zero height — keep room for the
     skeleton until @load fires */
  min-height: 160px;
}

.content-image {
  width: 100%;
  max-height: 60vh;
  object-fit: contain;
  cursor: zoom-in;
  display: block;
  background: var(--gn-surface-panel);
}

.content-image-skeleton {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: color-mix(in srgb, var(--gn-color-grey) 12%, var(--gn-surface-panel));
  border-bottom: 1px solid var(--gn-border-color-muted);
  color: var(--gn-color-text-dark);
}

.content-image-spinner {
  animation: content-image-spin 0.9s linear infinite;
  font-size: 22px;
}

@keyframes content-image-spin {
  to { transform: rotate(360deg); }
}

.content-video {
  width: 100%;
  max-height: 60vh;
  display: block;
  background: var(--gn-surface-panel);
}

.content-audio-wrap {
  background: var(--gn-surface-panel);
}

.content-text {
  max-height: 500px;
  overflow: auto;
  background: var(--gn-color-black);
}

.content-text-note {
  padding: 8px 14px;
  border-top: 1px solid var(--gn-border-color-muted);
  color: var(--gn-color-text-dark);
  font-size: 12px;
}

.content-source {
  max-height: 500px;
  overflow: auto;
  background: var(--gn-color-black);
}

.content-source-code,
.content-source-error {
  margin: 0;
  padding: 14px;
  white-space: pre;
  font-family: "IBM Plex Mono", monospace;
  font-size: 12px;
  line-height: 1.6;
  color: var(--gn-color-text-light);
}

.content-source-error {
  color: var(--gn-color-error);
}

.content-source-state {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 14px;
  color: var(--gn-color-text-dark);
  font-size: 12px;
}

.content-unknown {
  padding: 24px;
  text-align: center;
  color: var(--gn-color-text-dark);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

.unknown-icon {
  font-size: 2em;
  line-height: 1;
}

.unknown-name {
  font-size: 0.95em;
  font-weight: 500;
  color: var(--gn-color-text-light);
  word-break: break-word;
  max-width: 100%;
}

.unknown-open {
  /* Kit .btn defaults to width:100% — the unknown panel is a centered block */
  width: auto;
  text-decoration: none;
  font-size: 0.9em;
}

/* Kit badge variants replace the old per-type accent overrides */
</style>