Newer
Older
navi-1 / webclient / src / components / artifacts / composables / useArtifactsView.js
import { computed, ref, watch } from 'vue'

import { useLightbox } from '@/composables/useLightbox.js'
import { locale, t } from '@/i18n/index.js'
import { itemContentType } from '@/utils/artifactContent.js'
import {
  absoluteContentUrl,
  contentFileUrl,
  versionedContentUrl,
  viewerContentUrl,
} from '@/utils/contentLinks.js'
import {
  CONTENT_TYPE_ICONS,
  isFrameType,
  isFullscreenType,
  viewerNameFor,
} from '@/utils/contentTypes.js'
import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.js'

/**
 * The Artifacts tab: which published artifact is selected, its source or text
 * preview, and every URL the pane offers (preview, raw, download, share).
 */

const TEXT_PREVIEW_LIMIT = 1_000_000

export function useArtifactsView(chat) {
  const { open: openLightbox, openContent } = useLightbox()

  const selectedKey = ref(null)
  const copiedKey = ref(null)
  const showSource = ref(false)
  const sourceText = ref('')
  const sourceLoading = ref(false)
  const sourceError = ref('')
  // Text files are fetched into the preview rather than offered as a download.
  const textContent = ref('')
  const textLoading = ref(false)
  const textError = ref('')
  const textTruncated = ref(false)
  const textPreviewLimitLabel = `${Math.round(TEXT_PREVIEW_LIMIT / 1024)} KB`

  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))
  const highlightedText = computed(() => highlightSource(textContent.value, selectedSourceLanguage.value))

  function openViewedImage(src) {
    openLightbox(src)
  }

  async function loadText(item) {
    if (!item || textLoading.value || textContent.value) return
    textLoading.value = true
    textError.value = ''
    try {
      const res = await fetch(versionedUrl(item))
      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
    }
  }

  // A different artifact selected → start its text over, and fetch it right
  // away (the detail pane is always visible, unlike the card's collapsed body).
  watch(selected, (item) => {
    textContent.value = ''
    textError.value = ''
    textTruncated.value = false
    if (item && !showSource.value && contentType(item) === 'text') loadText(item)
  }, { immediate: true })

  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 = ''
  }

  // The backend detects the type; the name is the fallback for entries that
  // arrive without one. Both cards read the same table now.
  function contentType(item) {
    return itemContentType(item)
  }

  function iconClass(item) {
    return CONTENT_TYPE_ICONS[contentType(item)] || CONTENT_TYPE_ICONS.unknown
  }

  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(locale.value, { 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)
    return isFrameType(type)
      ? viewerUrl(item, viewerNameFor(type))
      : absoluteContentUrl(versionedUrl(item))
  }

  function canFullscreen(item) {
    return isFullscreenType(contentType(item))
  }

  function openFullscreen(item) {
    if (!item || !canFullscreen(item)) return
    openContent({
      url: absoluteContentUrl(versionedUrl(item)),
      type: contentType(item),
      title: item.title || item.filename,
    })
  }

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

  function sourceUrl(item) {
    const type = contentType(item)
    if (['html', 'svg'].includes(type)) return rawUrl(item)
    if (['stl', 'model'].includes(type) && 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 = t('artifacts.sourceFailed', { error: err?.message || err })
    } finally {
      sourceLoading.value = false
    }
  }

  function shareUrl(item) {
    if (['stl', 'model'].includes(contentType(item))) 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 isFrameType(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'
  }

  return {
    selectedKey,
    copiedKey,
    showSource,
    sourceText,
    sourceLoading,
    sourceError,
    textContent,
    textLoading,
    textError,
    textTruncated,
    textPreviewLimitLabel,
    artifacts,
    selected,
    selectedSourceLanguage,
    highlightedSource,
    highlightedText,
    openViewedImage,
    artifactKey,
    selectArtifact,
    contentType,
    iconClass,
    labelFor,
    versionedUrl,
    previewUrl,
    canFullscreen,
    openFullscreen,
    rawUrl,
    hasSource,
    toggleSource,
    downloadUrl,
    isFramePreview,
    copyLink,
    copyIcon,
  }
}