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>
      <span class="content-icon">{{ icon }}</span>
      <span class="content-title">{{ title }}</span>
      <span class="content-badge">{{ contentType }}</span>
      <span class="content-actions" @click.stop>
        <a :href="previewUrl" target="_blank" rel="noopener noreferrer" title="Open preview">
          <i class="ph ph-arrow-square-out"></i>
        </a>
        <a :href="downloadUrl" title="Download">
          <i class="ph ph-download-simple"></i>
        </a>
        <button
          v-if="hasSource"
          type="button"
          :title="showSource ? 'Show preview' : 'Show source'"
          @click="toggleSource"
        >
          <i :class="showSource ? 'ph ph-eye' : 'ph ph-code'"></i>
        </button>
        <button type="button" title="Copy file link" @click="copyLink">
          <i :class="copyIcon"></i>
        </button>
      </span>
      <i class="ph ph-caret-down content-chevron"></i>
    </summary>

    <div v-if="isOpen" class="content-body">
      <div v-if="showSource" class="content-source">
        <div v-if="sourceLoading" class="content-source-state">
          <span class="spinner"></span>
          <span>Loading source...</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>

      <!-- STL -->
      <iframe
        v-else-if="detectedContentType === 'stl'"
        :src="viewerUrl('stl')"
        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 -->
      <img
        v-else-if="detectedContentType === 'image'"
        :src="fileUrl"
        class="content-image"
        :alt="title"
        loading="lazy"
        @click="openLightbox"
      />

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

      <!-- Unknown -->
      <div v-else class="content-unknown">
        <div class="unknown-icon">{{ icon }}</div>
        <div class="unknown-name">{{ title }}</div>
        <a :href="fileUrl" target="_blank" rel="noopener noreferrer" class="unknown-open">
          Open file
        </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>
      <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">Arguments</div>
        <pre class="tool-code">{{ formatJson(tool.args) }}</pre>
      </div>
      <div class="tool-section">
        <div class="tool-section-label">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 { absoluteContentUrl, contentFileUrl, viewerContentUrl } from '@/utils/contentLinks.js'
import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.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)
const linkCopied = ref(false)
const showSource = ref(false)
const sourceText = ref('')
const sourceLoading = ref(false)
const sourceError = ref('')
const { open } = useLightbox()

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

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() : 'Content',
    }
  }
  return {}
})

const url = computed(() => metadata.value.url || '')
const fileUrl = computed(() => contentFileUrl(url.value))
const contentType = computed(() => metadata.value.content_type || 'unknown')
const title = computed(() => metadata.value.title || metadata.value.filename || 'Content')

const detectedContentType = computed(() => {
  if (contentType.value !== 'unknown') return contentType.value
  const ext = url.value.split('.').pop()?.toLowerCase()
  const map = {
    svg: 'svg', html: 'html', htm: 'html', pdf: 'pdf', stl: 'stl',
    png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', bmp: 'image',
    mp4: 'video', webm: 'video', mov: 'video', mkv: 'video',
  }
  return map[ext] || 'unknown'
})

const CONTENT_ICONS = {
  stl: '🧊',
  html: '🌐',
  svg: '🎨',
  pdf: '📄',
  image: '🖼️',
  video: '🎬',
  unknown: '📎'
}

const icon = computed(() => CONTENT_ICONS[contentType.value] || CONTENT_ICONS.unknown)

const contentTypeClass = computed(() => `is-${detectedContentType.value}`)
const previewUrl = computed(() => {
  const type = detectedContentType.value
  if (['stl', 'html', 'svg', 'pdf'].includes(type)) return viewerUrl(type)
  return absoluteContentUrl(fileUrl.value)
})
const shareUrl = computed(() => {
  if (detectedContentType.value === 'stl') return previewUrl.value
  return absoluteContentUrl(fileUrl.value)
})
const sourceUrl = computed(() => {
  if (['html', 'svg'].includes(detectedContentType.value)) return fileUrl.value
  if (detectedContentType.value === 'stl' && 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 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 (contentType.value === 'image') {
    open(fileUrl.value)
  }
}

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

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 = `Failed to load source: ${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>
.content-card {
  margin: 8px 0;
  background: var(--surface, #1e1e2e);
  border: 1px solid var(--border, #2a2a3e);
  overflow: hidden;
}

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

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

.content-icon {
  font-size: 1.2em;
  line-height: 1;
}

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

.content-badge {
  font-size: 0.7em;
  text-transform: uppercase;
  padding: 2px 8px;
  background: var(--accent-muted, #313244);
  color: var(--accent, #4ec9b0);
}

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

.content-actions a,
.content-actions button {
  width: 26px;
  height: 26px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border, #2a2a3e);
  background: var(--surface-elevated, #242438);
  color: var(--text, #cdd6f4);
  text-decoration: none;
  cursor: pointer;
}

.content-actions a:hover,
.content-actions button:hover {
  color: var(--accent, #4ec9b0);
  border-color: var(--accent, #4ec9b0);
}

.content-chevron {
  transition: transform 0.2s;
  color: var(--text-muted, #6c7086);
}

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

.content-body {
  border-top: 1px solid var(--border, #2a2a3e);
  overflow: auto;
}

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

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

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

.content-video {
  width: 100%;
  max-height: 60vh;
  display: block;
  background: var(--surface, #1e1e2e);
}

.content-source {
  max-height: 500px;
  overflow: auto;
  background: #11111b;
}

.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(--text, #cdd6f4);
}

.content-source-error {
  color: var(--error, #f7768e);
}

.content-source-state {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 14px;
  color: var(--text-muted, #6c7086);
  font-size: 12px;
}

.content-unknown {
  padding: 24px;
  text-align: center;
  color: var(--text-muted, #6c7086);
  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(--text, #cdd6f4);
  word-break: break-word;
  max-width: 100%;
}

.unknown-open {
  display: inline-block;
  margin-top: 4px;
  padding: 6px 14px;
  border-radius: 0;
  background: var(--accent-muted, #313244);
  color: var(--accent, #4ec9b0);
  text-decoration: none;
  font-size: 0.85em;
  font-weight: 500;
  transition: background 0.15s;
}

.unknown-open:hover {
  background: var(--border, #2a2a3e);
}

/* Type-specific accents */
.content-card.is-stl .content-badge { color: #f5c2e7; }
.content-card.is-html .content-badge { color: #89b4fa; }
.content-card.is-svg .content-badge { color: #a6e3a1; }
.content-card.is-pdf .content-badge { color: #fab387; }
.content-card.is-image .content-badge { color: #89dceb; }
.content-card.is-video .content-badge { color: #f38ba8; }
</style>