feat(chat): integrate podcast support into chat components

- Updated ChatMessage and ChatWindow components to handle podcast content alongside films and songs.
- Enhanced useContentPanel to extract and manage podcasts, allowing for richer media interactions.
- Added PodcastCard and PodcastGrid components for displaying podcast information.
- Improved UI elements to accommodate podcast selection and detail viewing.
- Updated styles for empty state icons and added new CSS for podcast-related elements.

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 19:57:44 +00:00
parent 7a0e42bf63
commit 49ec6c09b6
18 changed files with 890 additions and 39 deletions
@@ -5,9 +5,8 @@
:class="isDark ? '!border-b-white/10' : '!border-b-black/8'"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="isDark ? 'bg-accent/20' : 'bg-accent/10'">
<span class="text-accent text-sm font-bold">AI</span>
<div class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 overflow-hidden">
<span class="text-base" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<div class="min-w-0 flex-1 relative">
<button
@@ -30,6 +30,15 @@
/>
</div>
<div v-if="inlinePodcasts.length > 0" class="mt-3 space-y-1" @click.stop>
<PodcastCard
v-for="podcast in inlinePodcasts"
:key="podcast.id"
:podcast="podcast"
@select="handlePodcastSelect"
/>
</div>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] select-none"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ formattedTime }}</span>
@@ -47,6 +56,13 @@
>
View all {{ inlineSongs.length }} songs
</button>
<button
v-else-if="inlinePodcasts.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePodcasts.length }} podcasts
</button>
</div>
</div>
</div>
@@ -55,11 +71,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
import type { Film, Song } from '@aiui/core/types/content'
import type { Film, Song, Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import FilmCard from '@/components/content/FilmCard.vue'
import SongCard from '@/components/content/SongCard.vue'
import PodcastCard from '@/components/content/PodcastCard.vue'
const props = defineProps<{
message: Message
@@ -67,7 +84,7 @@ const props = defineProps<{
}>()
const { isDark } = useTheme()
const { extractAllFilms, extractAllSongs, stripContentTags, updatePanelFromText, openFilmDetail, openSongDetail, closeFilmDetail, closeSongDetail } = useContentPanel()
const { extractAllFilms, extractAllSongs, extractAllPodcasts, stripContentTags, updatePanelFromText, openFilmDetail, openSongDetail, openPodcastDetail, closeFilmDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const isUser = computed(() => props.message.role === 'user')
@@ -87,7 +104,12 @@ const inlineSongs = computed(() => {
return extractAllSongs(props.message.content)
})
const hasContext = computed(() => !isUser.value && (inlineFilms.value.length > 0 || inlineSongs.value.length > 0))
const inlinePodcasts = computed(() => {
if (isUser.value) return []
return extractAllPodcasts(props.message.content)
})
const hasContext = computed(() => !isUser.value && (inlineFilms.value.length > 0 || inlineSongs.value.length > 0 || inlinePodcasts.value.length > 0))
const displayText = computed(() => {
if (isUser.value) return props.message.content
@@ -102,18 +124,28 @@ const formattedTime = computed(() => {
function handleFilmSelect(film: Film) {
updatePanelFromText(props.message.content)
closeSongDetail()
closePodcastDetail()
openFilmDetail(film)
}
function handleSongSelect(song: Song) {
updatePanelFromText(props.message.content)
closeFilmDetail()
closePodcastDetail()
openSongDetail(song)
}
function handlePodcastSelect(podcast: Podcast) {
updatePanelFromText(props.message.content)
closeFilmDetail()
closeSongDetail()
openPodcastDetail(podcast)
}
function openPanel() {
closeFilmDetail()
closeSongDetail()
closePodcastDetail()
updatePanelFromText(props.message.content)
}
@@ -16,8 +16,8 @@
>
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center space-y-3 animate-fade-up">
<div class="w-16 h-16 rounded-2xl path-glass-card flex items-center justify-center mx-auto overflow-hidden">
<img src="/icon.svg" alt="AIUI" class="w-10 h-10 object-contain" />
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Start a conversation
@@ -1,11 +1,11 @@
<template>
<div class="relative flex-1 flex flex-col min-h-0 overflow-hidden">
<div
v-if="contextType === 'film' || contextType === 'song'"
v-if="contextType === 'film' || contextType === 'song' || contextType === 'podcast'"
class="flex-1 flex flex-col min-h-0"
>
<div
class="p-4 shrink-0 flex items-center justify-between"
class="p-4 shrink-0 flex items-center justify-between gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
@@ -14,10 +14,13 @@
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ contextLabel }}
</p>
<p class="text-[10px] font-mono uppercase tracking-[0.2em]"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
Surfacing
</p>
<div class="flex items-center gap-2 shrink-0">
<p class="text-[10px] font-mono uppercase tracking-[0.2em]"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
Surfacing
</p>
<slot name="header-actions" />
</div>
</div>
<LoadingFilmGrid :count="12" />
</div>
@@ -72,7 +75,7 @@ import LoadingFilmGrid from './LoadingFilmGrid.vue'
const props = withDefaults(
defineProps<{
contextType?: 'film' | 'song' | 'generic'
contextType?: 'film' | 'song' | 'podcast' | 'generic'
}>(),
{ contextType: 'film' }
)
@@ -82,6 +85,7 @@ const { isDark } = useTheme()
const contextLabel = computed(() => {
if (props.contextType === 'film') return 'Film recommendations'
if (props.contextType === 'song') return 'Song recommendations'
if (props.contextType === 'podcast') return 'Podcast recommendations'
return 'Content'
})
</script>
@@ -4,13 +4,16 @@
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredFilms.length }} films
</span>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredFilms.length }} films
</span>
<slot name="header-actions" />
</div>
</div>
<input
@@ -0,0 +1,73 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select', podcast)"
>
<div class="cover-card-sm shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="coverSrc"
:src="coverSrc"
:alt="podcast.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ podcast.title }}</p>
<p class="text-[11px] mt-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ podcast.host || 'Podcast' }}<template v-if="podcast.year"> · {{ podcast.year }}</template>
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="isExternal"
class="text-[9px] px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-info/15 text-info/70' : 'bg-info/10 text-blue-600'">
not in library
</span>
<span
v-for="src in podcast.sources.slice(0, 3)"
:key="src.type"
class="text-[9px] px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ src.type }}
</span>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
defineEmits<{ select: [podcast: Podcast] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.podcast.coverUrl || null
})
const fallbackCover = computed(() =>
generatePodcastCoverFallback(props.podcast.title, props.podcast.host)
)
const isExternal = computed(() => props.podcast.id.startsWith('ext-'))
</script>
@@ -0,0 +1,164 @@
<template>
<div class="podcast-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div class="w-full aspect-[16/7] flex items-center justify-center overflow-hidden bg-black/20">
<img
v-if="coverSrc"
:src="coverSrc"
:alt="podcast.title"
class="w-full h-full object-cover object-center block"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ podcast.title }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span v-if="podcast.host">{{ podcast.host }}</span>
<span v-if="podcast.year">{{ podcast.year }}</span>
<span v-if="podcast.episodeCount">{{ podcast.episodeCount }} episodes</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<p v-if="podcast.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ podcast.description }}
</p>
<div v-if="podcast.genres?.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in podcast.genres"
:key="genre"
class="text-[10px] px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Listen on</h4>
<div class="space-y-2">
<a
v-for="src in podcast.sources"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<template v-if="extListenLinks.length">
<a
v-for="link in extListenLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.podcast.coverUrl || null
})
const fallbackCover = computed(() =>
generatePodcastCoverFallback(props.podcast.title, props.podcast.host)
)
const q = computed(() =>
`${props.podcast.title} ${props.podcast.host ?? ''}`.trim().replace(/\s+/g, '+'),
)
const extListenLinks = computed(() => {
if (props.podcast.sources.length > 0) return []
return [
{ name: 'Fountain', url: `https://fountain.fm/search?q=${q.value}`, icon: '⚡', desc: 'Podcasting 2.0, Lightning' },
{ name: 'Podcast Index', url: `https://podcastindex.org/search?q=${q.value}`, icon: '📻', desc: 'Open podcast directory' },
{ name: 'YouTube', url: `https://youtube.com/results?search_query=${q.value}`, icon: '▶️', desc: 'Video podcasts' },
{ name: 'Rumble', url: `https://rumble.com/search/video?q=${q.value}`, icon: '📺', desc: 'Video & podcasts' },
{ name: 'Odysee', url: `https://odysee.com/$/search?q=${q.value}`, icon: '🔗', desc: 'Decentralized' },
]
})
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
fountain: '⚡',
rumble: '📺',
youtube: '▶️',
podcastindex: '📻',
castopod: '🦣',
odysee: '🔗',
podverse: '🎧',
ipfs: '🌐',
rss: '📡',
}
return icons[type] ?? '🎙️'
}
</script>
@@ -0,0 +1,168 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredPodcasts.length }} podcasts
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search podcasts..."
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
<div v-if="topGenres.length > 0" class="flex flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="podcast in filteredPodcasts"
:key="podcast.id"
class="group flex flex-col items-stretch text-left w-full"
@click="$emit('selectPodcast', podcast)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<img
v-if="coverSrc(podcast)"
:src="coverSrc(podcast)!"
:alt="podcast.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onCoverError(podcast)"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(podcast)})` }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-[11px] font-semibold text-white/90 leading-tight truncate">
{{ podcast.title }}
</p>
<p class="text-[9px] text-white/40 truncate mt-0.5">{{ podcast.host || 'Podcast' }}</p>
</div>
<div class="absolute top-1.5 right-1.5 flex gap-0.5 flex-wrap justify-end max-w-[60%]">
<span
v-for="src in podcast.sources.slice(0, 2)"
:key="src.type"
class="text-[8px] px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"
>
{{ src.type }}
</span>
</div>
</div>
</div>
<p class="text-xs font-semibold mt-2 truncate px-0.5"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ podcast.title }}
</p>
</button>
</div>
<div v-if="filteredPodcasts.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No podcasts match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
podcasts: Podcast[]
title?: string
}>(), {
title: 'Recommended Podcasts',
})
defineEmits<{ selectPodcast: [podcast: Podcast] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
function coverSrc(podcast: Podcast): string | null {
if (failedCovers.value.has(podcast.id)) return null
return podcast.coverUrl || null
}
function fallbackFor(podcast: Podcast): string {
return generatePodcastCoverFallback(podcast.title, podcast.host)
}
function onCoverError(podcast: Podcast) {
failedCovers.value.add(podcast.id)
failedCovers.value = new Set(failedCovers.value)
}
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const p of props.podcasts) {
for (const g of p.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredPodcasts = computed(() => {
let result = props.podcasts
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(p) =>
p.title.toLowerCase().includes(q) ||
(p.host ?? '').toLowerCase().includes(q) ||
(p.genres ?? []).some((g) => g.toLowerCase().includes(q))
)
}
if (activeGenre.value) {
result = result.filter((p) => (p.genres ?? []).includes(activeGenre.value!))
}
return result
})
</script>
@@ -4,13 +4,16 @@
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredSongs.length }} songs
</span>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredSongs.length }} songs
</span>
<slot name="header-actions" />
</div>
</div>
<input