<template>
<details
ref="detailsEl"
class="content-card"
:class="contentTypeClass"
open
>
<summary>
<span class="content-icon">{{ icon }}</span>
<span class="content-title">{{ title }}</span>
<span class="content-badge">{{ contentType }}</span>
<i class="ph ph-caret-down content-chevron"></i>
</summary>
<div class="content-body">
<!-- STL -->
<iframe
v-if="detectedContentType === 'stl'"
:src="viewerUrl('stl')"
class="content-iframe"
sandbox="allow-scripts"
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="url"
class="content-image"
:alt="title"
loading="lazy"
@click="openLightbox"
/>
<!-- Video -->
<video
v-else-if="detectedContentType === 'video'"
:src="url"
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="url" target="_blank" rel="noopener noreferrer" class="unknown-open">
Open file
</a>
</div>
</div>
</details>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useLightbox } from '@/composables/useLightbox.js'
const props = defineProps({
tool: { type: Object, required: true }
})
const detailsEl = ref(null)
const { open } = useLightbox()
// 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 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}`)
function viewerUrl(viewerType) {
return `/content-viewers/${viewerType}.html?url=${encodeURIComponent(url.value)}`
}
function openLightbox() {
if (contentType.value === 'image') {
open(url.value)
}
}
</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-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;
&::-webkit-scrollbar { width: 10px; }
&::-webkit-scrollbar-track { width: 10px; background: #16161e; cursor: pointer; }
&::-webkit-scrollbar-thumb { width: 10px; background: #414868; cursor: default; }
&::-webkit-scrollbar-corner { background: transparent; height: 1px; }
&::-webkit-scrollbar-button { display: none; }
}
.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-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>