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