Newer
Older
navi-1 / webclient / src / components / artifacts / ArtifactsPanel.vue
@Eugene Sukhodolskiy Eugene Sukhodolskiy on 30 Apr 12 KB Add artifact source previews
<template>
  <aside class="artifacts-panel" :class="{ 'is-open': open }" aria-label="Session artifacts">
    <div class="artifacts-header">
      <div>
        <div class="artifacts-title">Artifacts</div>
        <div class="artifacts-count">{{ artifacts.length }} published</div>
      </div>
      <button class="btn-icon artifacts-close" title="Close artifacts" @click="emit('close')">
        <i class="ph ph-x"></i>
      </button>
    </div>

    <div v-if="!artifacts.length" class="artifacts-empty">
      <i class="ph ph-files"></i>
      <span>No published files</span>
    </div>

    <template v-else>
      <div class="artifacts-list">
        <button
          v-for="item in artifacts"
          :key="item.id || item.filename"
          class="artifact-item"
          :class="{ 'is-active': selectedKey === artifactKey(item) }"
          @click="selectArtifact(item)"
        >
          <span class="artifact-icon"><i :class="iconClass(item)"></i></span>
          <span class="artifact-info">
            <span class="artifact-title">{{ item.title || item.filename }}</span>
            <span class="artifact-meta">{{ labelFor(item) }}</span>
          </span>
        </button>
      </div>

      <div v-if="selected" class="artifact-detail">
        <div class="artifact-detail-header">
          <div class="artifact-detail-title">{{ selected.title || selected.filename }}</div>
          <div class="artifact-detail-filename">{{ selected.filename }}</div>
        </div>

        <div class="artifact-actions">
          <a class="artifact-action" :href="previewUrl(selected)" target="_blank" rel="noopener noreferrer" title="Open preview">
            <i class="ph ph-arrow-square-out"></i>
          </a>
          <a class="artifact-action" :href="rawUrl(selected)" target="_blank" rel="noopener noreferrer" title="Open raw file">
            <i class="ph ph-file"></i>
          </a>
          <a class="artifact-action" :href="downloadUrl(selected)" title="Download">
            <i class="ph ph-download-simple"></i>
          </a>
          <button
            v-if="hasSource(selected)"
            class="artifact-action"
            :title="showSource ? 'Show preview' : 'Show source'"
            @click="toggleSource(selected)"
          >
            <i :class="showSource ? 'ph ph-eye' : 'ph ph-code'"></i>
          </button>
          <button class="artifact-action" title="Copy file link" @click="copyLink(selected)">
            <i :class="copyIcon(selected)"></i>
          </button>
        </div>

        <div class="artifact-preview">
          <div v-if="showSource" class="artifact-source">
            <div v-if="sourceLoading" class="artifact-source-state">
              <span class="spinner"></span>
              <span>Loading source...</span>
            </div>
            <pre v-else-if="sourceError" class="artifact-source-error">{{ sourceError }}</pre>
            <pre v-else class="artifact-source-code"><code class="hljs" :class="`language-${selectedSourceLanguage}`" v-html="highlightedSource"></code></pre>
          </div>
          <iframe
            v-else-if="isFramePreview(selected)"
            :src="previewUrl(selected)"
            class="artifact-frame"
            sandbox="allow-scripts allow-same-origin"
          />
          <img
            v-else-if="contentType(selected) === 'image'"
            :src="versionedUrl(selected)"
            :alt="selected.title || selected.filename"
            class="artifact-image"
          />
          <video
            v-else-if="contentType(selected) === 'video'"
            :src="versionedUrl(selected)"
            class="artifact-video"
            controls
            playsinline
          />
          <div v-else class="artifact-no-preview">
            <i class="ph ph-file"></i>
            <span>Preview unavailable</span>
          </div>
        </div>
      </div>
    </template>
  </aside>
</template>

<script setup>
import { computed, ref, watch } from 'vue'
import { useChatStore } from '@/stores/chat.js'
import { absoluteContentUrl, contentFileUrl, versionedContentUrl, viewerContentUrl } from '@/utils/contentLinks.js'
import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.js'

defineProps({
  open: { type: Boolean, default: false }
})

const emit = defineEmits(['close'])
const chat = useChatStore()
const selectedKey = ref(null)
const copiedKey = ref(null)
const showSource = ref(false)
const sourceText = ref('')
const sourceLoading = ref(false)
const sourceError = ref('')

const artifacts = computed(() => chat.artifacts)
const selected = computed(() => artifacts.value.find(item => artifactKey(item) === selectedKey.value) || artifacts.value[0] || null)
const selectedSourceLanguage = computed(() => selected.value ? sourceLanguage(selected.value) : 'plaintext')
const highlightedSource = computed(() => highlightSource(sourceText.value, selectedSourceLanguage.value))

watch(
  artifacts,
  (items) => {
    if (!items.length) {
      selectedKey.value = null
      return
    }
    if (!selectedKey.value || !items.some(item => artifactKey(item) === selectedKey.value)) {
      selectedKey.value = artifactKey(items[0])
    }
  },
  { immediate: true }
)

function artifactKey(item) {
  return item.id || item.filename
}

function selectArtifact(item) {
  selectedKey.value = artifactKey(item)
  showSource.value = false
  sourceText.value = ''
  sourceError.value = ''
}

function contentType(item) {
  return item.content_type || 'unknown'
}

function iconClass(item) {
  const map = {
    stl: 'ph ph-cube',
    html: 'ph ph-browser',
    svg: 'ph ph-vector-three',
    pdf: 'ph ph-file-pdf',
    image: 'ph ph-image',
    video: 'ph ph-video',
  }
  return map[contentType(item)] || 'ph ph-paperclip'
}

function labelFor(item) {
  const type = contentType(item).toUpperCase()
  const date = item.updated_at || item.created_at
  if (!date) return type
  return `${type} ยท ${formatDate(date)}`
}

function formatDate(value) {
  const date = new Date(value)
  if (Number.isNaN(date.getTime())) return value
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}

function versionedUrl(item) {
  return versionedContentUrl(item.url, item.updated_at || item.created_at || '')
}

function viewerUrl(item, viewerType) {
  return viewerContentUrl(viewerType, versionedUrl(item))
}

function previewUrl(item) {
  const type = contentType(item)
  if (['stl', 'html', 'svg', 'pdf'].includes(type)) return viewerUrl(item, type)
  return absoluteContentUrl(versionedUrl(item))
}

function rawUrl(item) {
  return contentFileUrl(item.url)
}

function sourceUrl(item) {
  const type = contentType(item)
  if (['html', 'svg'].includes(type)) return rawUrl(item)
  if (type === 'stl' && item.source_url) return contentFileUrl(item.source_url)
  return ''
}

function hasSource(item) {
  return Boolean(sourceUrl(item))
}

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

async function loadSource(item) {
  const url = sourceUrl(item)
  if (!url || sourceText.value || sourceLoading.value) return
  sourceLoading.value = true
  sourceError.value = ''
  try {
    const res = await fetch(url)
    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
  }
}

function shareUrl(item) {
  if (contentType(item) === 'stl') return previewUrl(item)
  return absoluteContentUrl(versionedUrl(item))
}

function downloadUrl(item) {
  const url = contentFileUrl(item.download_url || item.url)
  if (item.download_url || !url) return url
  return `${url}${url.includes('?') ? '&' : '?'}download=1`
}

function isFramePreview(item) {
  return ['stl', 'html', 'svg', 'pdf'].includes(contentType(item))
}

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

function copyIcon(item) {
  return copiedKey.value === artifactKey(item) ? 'ph ph-check' : 'ph ph-copy'
}
</script>

<style scoped>
.artifacts-panel {
  width: min(600px, 42vw);
  min-width: min(600px, 42vw);
  max-width: min(600px, 42vw);
  display: flex;
  flex-direction: column;
  background: var(--surface, #1e1e2e);
  border-left: 1px solid var(--border, #2a2a3e);
  min-height: 0;
  margin-right: calc(-1 * min(600px, 42vw));
  opacity: 0;
  pointer-events: none;
  transform: translateX(100%);
  transition:
    margin-right 0.22s ease,
    opacity 0.22s ease,
    transform 0.22s ease;
}

.artifacts-panel.is-open {
  margin-right: 0;
  opacity: 1;
  pointer-events: auto;
  transform: translateX(0);
}

.artifacts-header {
  height: 64px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 0 14px;
  border-bottom: 1px solid var(--border, #2a2a3e);
}

.artifacts-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--text, #cdd6f4);
}

.artifacts-count {
  margin-top: 2px;
  font-size: 12px;
  color: var(--text-muted, #6c7086);
}

.artifacts-empty {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: var(--text-muted, #6c7086);
  font-size: 13px;
}

.artifacts-empty i {
  font-size: 28px;
}

.artifacts-list {
  max-height: 34%;
  overflow-y: auto;
  border-bottom: 1px solid var(--border, #2a2a3e);
}

.artifact-item {
  width: 100%;
  min-height: 58px;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  border: 0;
  border-left: 3px solid transparent;
  background: transparent;
  color: var(--text, #cdd6f4);
  text-align: left;
  cursor: pointer;
}

.artifact-item:hover {
  background: rgba(255, 255, 255, 0.03);
}

.artifact-item.is-active {
  border-left-color: var(--accent, #4ec9b0);
  background: rgba(78, 201, 176, 0.08);
}

.artifact-icon {
  width: 28px;
  height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--accent, #4ec9b0);
  background: var(--accent-muted, #313244);
}

.artifact-info {
  min-width: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.artifact-title,
.artifact-detail-title,
.artifact-detail-filename {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.artifact-title {
  font-size: 13px;
  font-weight: 500;
}

.artifact-meta {
  font-size: 11px;
  color: var(--text-muted, #6c7086);
}

.artifact-detail {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.artifact-detail-header {
  padding: 12px 14px 8px;
}

.artifact-detail-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--text, #cdd6f4);
}

.artifact-detail-filename {
  margin-top: 3px;
  font-size: 12px;
  color: var(--text-muted, #6c7086);
}

.artifact-actions {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(42px, 1fr));
  gap: 6px;
  padding: 0 14px 12px;
}

.artifact-action {
  height: 32px;
  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;
}

.artifact-action:hover {
  border-color: var(--accent, #4ec9b0);
  color: var(--accent, #4ec9b0);
}

.artifact-preview {
  flex: 1;
  min-height: 0;
  border-top: 1px solid var(--border, #2a2a3e);
  background: #11111b;
}

.artifact-frame,
.artifact-image,
.artifact-video {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

.artifact-image,
.artifact-video {
  object-fit: contain;
}

.artifact-source {
  height: 100%;
  overflow: auto;
  background: #11111b;
}

.artifact-source-code,
.artifact-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);
}

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

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

.artifact-no-preview {
  height: 100%;
  min-height: 220px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: var(--text-muted, #6c7086);
  font-size: 13px;
}

.artifact-no-preview i {
  font-size: 28px;
}

@media (max-width: 980px) {
  .artifacts-panel {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    z-index: 120;
    margin-right: 0;
    transform: translateX(100%);
    width: 100vw;
    min-width: 100vw;
    max-width: 100vw;
  }

  .artifacts-panel.is-open {
    transform: translateX(0);
  }
}

@media (min-width: 981px) {
  .artifacts-close {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .artifacts-panel {
    transition: none;
  }
}
</style>