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:
@@ -82,7 +82,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.9dj45k1er1c"
|
||||
"revision": "0.hcs39iqdme4"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
|
||||
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
import { mockPodcasts } from '@/mocks/podcasts'
|
||||
|
||||
const filmContext = mockFilms.map((f) =>
|
||||
`- [${f.id}] "${f.title}" (${f.year}) dir. ${f.director} | ${f.genres.join(', ')} | ${f.rating}/10 | On: ${f.sources.map(s => s.type).join(', ')}`
|
||||
@@ -17,7 +18,11 @@ const songContext = mockSongs.map((s) =>
|
||||
`- [${s.id}] "${s.title}" by ${s.artist}${s.album ? ` (${s.album})` : ''}${s.year ? ` (${s.year})` : ''} | ${(s.genres ?? []).join(', ')} | On: ${(s.sources ?? []).map(x => x.type).join(', ')}`
|
||||
).join('\n')
|
||||
|
||||
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films and songs).
|
||||
const podcastContext = mockPodcasts.map((p) =>
|
||||
`- [${p.id}] "${p.title}" by ${p.host ?? 'Unknown'}${p.year ? ` (${p.year})` : ''} | ${(p.genres ?? []).join(', ')} | On: ${p.sources.map(x => x.type).join(', ')}`
|
||||
).join('\n')
|
||||
|
||||
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films, songs, and podcasts).
|
||||
|
||||
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]].
|
||||
|
||||
@@ -26,6 +31,11 @@ const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the u
|
||||
- Other songs: [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
|
||||
Never list songs in plain text only—each recommendation must have a tag so the UI can show playable cards.
|
||||
|
||||
**Podcasts:** When recommending or discussing podcasts, use tags:
|
||||
- Library podcasts: [[podcast:ID]] where ID is the podcast's id (e.g. [[podcast:p1]]).
|
||||
- Other podcasts: [[podcast_ext:Title|Host|Year]] (year optional), e.g. [[podcast_ext:What Bitcoin Did|Peter McCormack|2018]].
|
||||
Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Castopod, Odysee, Rumble, YouTube, Podverse.
|
||||
|
||||
**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo.
|
||||
|
||||
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
|
||||
@@ -34,7 +44,10 @@ The user's film library:
|
||||
${filmContext}
|
||||
|
||||
The user's song library:
|
||||
${songContext}`
|
||||
${songContext}
|
||||
|
||||
The user's podcast library:
|
||||
${podcastContext}`
|
||||
|
||||
const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? ''
|
||||
const hasOpenRouter = !!openrouterApiKey
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Film, Song } from '@aiui/core/types/content'
|
||||
import type { Film, Song, Podcast } from '@aiui/core/types/content'
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
import { mockPodcasts } from '@/mocks/podcasts'
|
||||
import { generatePosterFallback } from '@/composables/useImageFallback'
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const panelFilms = ref<Film[]>([])
|
||||
const panelSongs = ref<Song[]>([])
|
||||
const panelPodcasts = ref<Podcast[]>([])
|
||||
const selectedFilm = ref<Film | null>(null)
|
||||
const selectedSong = ref<Song | null>(null)
|
||||
const selectedPodcast = ref<Podcast | null>(null)
|
||||
const panelTitle = ref('Recommended Films')
|
||||
const contentType = ref<'film' | 'song'>('film')
|
||||
const contentType = ref<'film' | 'song' | 'podcast'>('film')
|
||||
|
||||
const FILM_TAG_RE = /\[\[film:(f?\d+)\]\]/gi
|
||||
const FILM_EXT_RE = /\[\[film_ext:([^|]+)\|(\d{4})\|([^\]]+)\]\]/gi
|
||||
const SONG_TAG_RE = /\[\[song:(s?\d+)\]\]/gi
|
||||
const SONG_EXT_RE = /\[\[song_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
|
||||
const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
|
||||
export function useContentPanel() {
|
||||
function normalizeFilmId(raw: string): string {
|
||||
@@ -198,14 +203,67 @@ export function useContentPanel() {
|
||||
return [...libMatches, ...fromPatterns]
|
||||
}
|
||||
|
||||
function normalizePodcastId(raw: string): string {
|
||||
return raw.startsWith('p') ? raw : `p${raw}`
|
||||
}
|
||||
|
||||
function extractPodcastIds(text: string): string[] {
|
||||
const ids: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(PODCAST_TAG_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const id = normalizePodcastId(match[1])
|
||||
if (!ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function resolvePodcasts(ids: string[]): Podcast[] {
|
||||
return ids
|
||||
.map((id) => mockPodcasts.find((p) => p.id === id))
|
||||
.filter((p): p is Podcast => !!p)
|
||||
}
|
||||
|
||||
function extractExternalPodcasts(text: string): Podcast[] {
|
||||
const podcasts: Podcast[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(PODCAST_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const host = match[2].trim()
|
||||
const year = match[3] ? parseInt(match[3], 10) : undefined
|
||||
const key = `${title.toLowerCase()}|${host.toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
podcasts.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
host,
|
||||
year,
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return podcasts
|
||||
}
|
||||
|
||||
function extractAllPodcasts(text: string): Podcast[] {
|
||||
const libraryPodcasts = resolvePodcasts(extractPodcastIds(text))
|
||||
const externalPodcasts = extractExternalPodcasts(text)
|
||||
return [...libraryPodcasts, ...externalPodcasts]
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string) {
|
||||
const songs = extractAllSongs(text)
|
||||
const films = extractAllFilms(text)
|
||||
const podcasts = extractAllPodcasts(text)
|
||||
|
||||
if (songs.length > 0) {
|
||||
panelSongs.value = songs
|
||||
panelFilms.value = []
|
||||
panelPodcasts.value = []
|
||||
selectedFilm.value = null
|
||||
selectedPodcast.value = null
|
||||
contentType.value = 'song'
|
||||
panelTitle.value = songs.length === 1
|
||||
? songs[0].title
|
||||
@@ -214,12 +272,25 @@ export function useContentPanel() {
|
||||
} else if (films.length > 0) {
|
||||
panelFilms.value = films
|
||||
panelSongs.value = []
|
||||
panelPodcasts.value = []
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
contentType.value = 'film'
|
||||
panelTitle.value = films.length === 1
|
||||
? films[0].title
|
||||
: `${films.length} Recommended Films`
|
||||
panelOpen.value = true
|
||||
} else if (podcasts.length > 0) {
|
||||
panelPodcasts.value = podcasts
|
||||
panelFilms.value = []
|
||||
panelSongs.value = []
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
contentType.value = 'podcast'
|
||||
panelTitle.value = podcasts.length === 1
|
||||
? podcasts[0].title
|
||||
: `${podcasts.length} Recommended Podcasts`
|
||||
panelOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,13 +310,22 @@ export function useContentPanel() {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripPodcastTags(text: string): string {
|
||||
return text
|
||||
.replace(PODCAST_TAG_RE, '')
|
||||
.replace(PODCAST_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripContentTags(text: string): string {
|
||||
return stripFilmTags(stripSongTags(text))
|
||||
return stripFilmTags(stripSongTags(stripPodcastTags(text)))
|
||||
}
|
||||
|
||||
function openFilmDetail(film: Film) {
|
||||
selectedFilm.value = film
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function closeFilmDetail() {
|
||||
@@ -255,44 +335,74 @@ export function useContentPanel() {
|
||||
function openSongDetail(song: Song) {
|
||||
selectedSong.value = song
|
||||
selectedFilm.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function closeSongDetail() {
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function openPodcastDetail(podcast: Podcast) {
|
||||
selectedPodcast.value = podcast
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function closePodcastDetail() {
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen.value = false
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function showAllFilms() {
|
||||
panelFilms.value = [...mockFilms]
|
||||
panelSongs.value = []
|
||||
panelPodcasts.value = []
|
||||
panelTitle.value = 'Your Film Library'
|
||||
contentType.value = 'film'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function showAllSongs() {
|
||||
panelFilms.value = []
|
||||
panelSongs.value = [...mockSongs]
|
||||
panelPodcasts.value = []
|
||||
panelTitle.value = 'Your Song Library'
|
||||
contentType.value = 'song'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function showAllPodcasts() {
|
||||
panelFilms.value = []
|
||||
panelSongs.value = []
|
||||
panelPodcasts.value = [...mockPodcasts]
|
||||
panelTitle.value = 'Your Podcast Library'
|
||||
contentType.value = 'podcast'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
panelOpen,
|
||||
panelFilms,
|
||||
panelSongs,
|
||||
panelPodcasts,
|
||||
selectedFilm,
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
panelTitle,
|
||||
contentType,
|
||||
extractFilmIds,
|
||||
@@ -301,16 +411,23 @@ export function useContentPanel() {
|
||||
extractSongIds,
|
||||
resolveSongs,
|
||||
extractAllSongs,
|
||||
extractPodcastIds,
|
||||
resolvePodcasts,
|
||||
extractAllPodcasts,
|
||||
updatePanelFromText,
|
||||
stripFilmTags,
|
||||
stripSongTags,
|
||||
stripPodcastTags,
|
||||
stripContentTags,
|
||||
openFilmDetail,
|
||||
closeFilmDetail,
|
||||
openSongDetail,
|
||||
closeSongDetail,
|
||||
openPodcastDetail,
|
||||
closePodcastDetail,
|
||||
closePanel,
|
||||
showAllFilms,
|
||||
showAllSongs,
|
||||
showAllPodcasts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,19 @@ function saveSessionCache(): void {
|
||||
|
||||
loadSessionCache()
|
||||
|
||||
export function generatePodcastCoverFallback(title: string, host?: string): string {
|
||||
const hue = [...(title + (host ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<rect width="200" height="200" fill="hsl(${hue}, 30%, 14%)"/>
|
||||
<rect x="2" y="2" width="196" height="196" rx="8" fill="none" stroke="hsl(${hue}, 40%, 25%)" stroke-width="2"/>
|
||||
<circle cx="100" cy="80" r="25" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="4"/>
|
||||
<path d="M100 105 v25 M85 130 h30 M100 155 v15 M80 170 h40" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="3" stroke-linecap="round"/>
|
||||
<text x="100" y="115" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="system-ui" font-size="12" font-weight="600">${escapeXml(title.length > 14 ? title.slice(0, 12) + '…' : title)}</text>
|
||||
${host ? `<text x="100" y="132" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="9">${escapeXml(host.length > 16 ? host.slice(0, 14) + '…' : host)}</text>` : ''}
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
export function generateSongCoverFallback(title: string, artist?: string): string {
|
||||
const hue = [...(title + (artist ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Podcast } from '@aiui/core/types/content'
|
||||
|
||||
export const mockPodcasts: Podcast[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
title: 'What Bitcoin Did',
|
||||
host: 'Peter McCormack',
|
||||
description: 'Interview-based podcast exploring Bitcoin, freedom, and the future of money.',
|
||||
coverUrl: undefined,
|
||||
year: 2018,
|
||||
episodeCount: 400,
|
||||
genres: ['Bitcoin', 'Finance', 'Tech'],
|
||||
sources: [
|
||||
{ type: 'fountain', name: 'Fountain', url: 'https://fountain.fm/show/whatbitcoindid', icon: '⚡' },
|
||||
{ type: 'podcastindex', name: 'Podcast Index', url: 'https://podcastindex.org/podcast/123456', icon: '📻' },
|
||||
{ type: 'youtube', name: 'YouTube', url: 'https://youtube.com/@WhatBitcoinDid', icon: '▶️' },
|
||||
{ type: 'rss', name: 'RSS', url: 'https://www.whatbitcoindid.com/podcast?format=rss', icon: '📡' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
title: 'The Audacity to Podcast',
|
||||
host: 'Daniel J. Lewis',
|
||||
description: 'Podcasting tips, strategies, and news. Podcasting 2.0 focused.',
|
||||
coverUrl: undefined,
|
||||
year: 2010,
|
||||
episodeCount: 500,
|
||||
genres: ['Podcasting', 'Tech', 'How-To'],
|
||||
sources: [
|
||||
{ type: 'fountain', name: 'Fountain', url: 'https://fountain.fm/show/1I7TKhOPXJz9MDhG5gqh', icon: '⚡' },
|
||||
{ type: 'youtube', name: 'YouTube', url: 'https://youtube.com/@audacitytopodcast', icon: '▶️' },
|
||||
{ type: 'castopod', name: 'Castopod', url: 'https://castopod.example/audacity', icon: '🦣' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
title: 'Tales from the Crypt',
|
||||
host: 'Marty Bent',
|
||||
description: 'Bitcoin, Austrian economics, and the importance of sound money.',
|
||||
coverUrl: undefined,
|
||||
year: 2017,
|
||||
episodeCount: 300,
|
||||
genres: ['Bitcoin', 'Economics', 'Finance'],
|
||||
sources: [
|
||||
{ type: 'fountain', name: 'Fountain', url: 'https://fountain.fm/show/talesfromthecrypt', icon: '⚡' },
|
||||
{ type: 'odysee', name: 'Odysee', url: 'https://odysee.com/@tftc', icon: '🔗' },
|
||||
{ type: 'rumble', name: 'Rumble', url: 'https://rumble.com/c/tftc', icon: '📺' },
|
||||
{ type: 'youtube', name: 'YouTube', url: 'https://youtube.com/@TalesFromTheCrypt', icon: '▶️' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
title: 'Stephan Livera Podcast',
|
||||
host: 'Stephan Livera',
|
||||
description: 'Bitcoin, Lightning Network, and sovereign individual topics.',
|
||||
coverUrl: undefined,
|
||||
year: 2018,
|
||||
episodeCount: 450,
|
||||
genres: ['Bitcoin', 'Lightning', 'Tech'],
|
||||
sources: [
|
||||
{ type: 'fountain', name: 'Fountain', url: 'https://fountain.fm/show/stephanlivera', icon: '⚡' },
|
||||
{ type: 'podcastindex', name: 'Podcast Index', url: 'https://podcastindex.org/podcast/789012', icon: '📻' },
|
||||
{ type: 'podverse', name: 'Podverse', url: 'https://podverse.fm/podcast/stephan-livera', icon: '🎧' },
|
||||
{ type: 'rss', name: 'RSS', url: 'https://stephanlivera.com/feed/', icon: '📡' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
title: 'Hell Money',
|
||||
host: 'Brittany Kaiser & Patrick Wood',
|
||||
description: 'Investigative podcast on financial crime, surveillance, and control systems.',
|
||||
coverUrl: undefined,
|
||||
year: 2022,
|
||||
episodeCount: 80,
|
||||
genres: ['True Crime', 'Finance', 'Investigative'],
|
||||
sources: [
|
||||
{ type: 'rumble', name: 'Rumble', url: 'https://rumble.com/c/hellmoney', icon: '📺' },
|
||||
{ type: 'youtube', name: 'YouTube', url: 'https://youtube.com/@HellMoney', icon: '▶️' },
|
||||
{ type: 'odysee', name: 'Odysee', url: 'https://odysee.com/@HellMoney', icon: '🔗' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
title: 'Cypherpunk Bitstream',
|
||||
host: 'Brandon Zemp',
|
||||
description: 'Decentralization, privacy tech, and cypherpunk philosophy.',
|
||||
coverUrl: undefined,
|
||||
year: 2020,
|
||||
episodeCount: 120,
|
||||
genres: ['Privacy', 'Bitcoin', 'Decentralization'],
|
||||
sources: [
|
||||
{ type: 'fountain', name: 'Fountain', url: 'https://fountain.fm/show/cypherpunkbitstream', icon: '⚡' },
|
||||
{ type: 'castopod', name: 'Castopod', url: 'https://castopod.example/cypherpunk', icon: '🦣' },
|
||||
{ type: 'ipfs', name: 'IPFS', url: 'ipfs://QmCypherpunkBitstream...', icon: '🌐' },
|
||||
{ type: 'rss', name: 'RSS', url: 'https://cypherpunkbitstream.com/feed', icon: '📡' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function searchPodcasts(query: string): Podcast[] {
|
||||
const q = query.toLowerCase()
|
||||
return mockPodcasts.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(q) ||
|
||||
(p.host ?? '').toLowerCase().includes(q) ||
|
||||
(p.genres ?? []).some((g) => g.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
@@ -14,12 +14,26 @@
|
||||
|
||||
<!-- Content surface (main area) -->
|
||||
<main
|
||||
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col"
|
||||
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col relative"
|
||||
:class="[
|
||||
panelSide === 'left' ? 'order-last' : 'order-first',
|
||||
(selectedFilm || selectedSong) && 'detail-active'
|
||||
(selectedFilm || selectedSong || selectedPodcast) && 'detail-active'
|
||||
]"
|
||||
>
|
||||
<button
|
||||
v-if="(selectedFilm || selectedSong || selectedPodcast) && (panelOpen || chatStore.isStreaming)"
|
||||
class="absolute top-3 right-3 z-10 p-2 rounded-lg transition-colors"
|
||||
:class="isDark
|
||||
? 'text-white/70 hover:bg-white/10'
|
||||
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
|
||||
title="Clear content"
|
||||
aria-label="Clear content"
|
||||
@click="closePanel"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<div v-if="panelOpen" key="content" class="flex-1 min-h-0 flex flex-col">
|
||||
<FilmDetail
|
||||
@@ -32,30 +46,105 @@
|
||||
:song="selectedSong"
|
||||
@back="closeSongDetail"
|
||||
/>
|
||||
<PodcastDetail
|
||||
v-else-if="selectedPodcast"
|
||||
:podcast="selectedPodcast"
|
||||
@back="closePodcastDetail"
|
||||
/>
|
||||
<FilmGrid
|
||||
v-else-if="contentType === 'film'"
|
||||
:films="panelFilms"
|
||||
:title="panelTitle"
|
||||
@select-film="openFilmDetail"
|
||||
/>
|
||||
>
|
||||
<template #header-actions>
|
||||
<button
|
||||
class="p-2 rounded-lg transition-colors -mr-1"
|
||||
:class="isDark
|
||||
? 'text-white/70 hover:bg-white/10'
|
||||
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
|
||||
title="Clear content"
|
||||
aria-label="Clear content"
|
||||
@click="closePanel"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</FilmGrid>
|
||||
<SongGrid
|
||||
v-else
|
||||
v-else-if="contentType === 'song'"
|
||||
:songs="panelSongs"
|
||||
:title="panelTitle"
|
||||
@select-song="openSongDetail"
|
||||
/>
|
||||
>
|
||||
<template #header-actions>
|
||||
<button
|
||||
class="p-2 rounded-lg transition-colors -mr-1"
|
||||
:class="isDark
|
||||
? 'text-white/70 hover:bg-white/10'
|
||||
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
|
||||
title="Clear content"
|
||||
aria-label="Clear content"
|
||||
@click="closePanel"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</SongGrid>
|
||||
<PodcastGrid
|
||||
v-else
|
||||
:podcasts="panelPodcasts"
|
||||
:title="panelTitle"
|
||||
@select-podcast="openPodcastDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button
|
||||
class="p-2 rounded-lg transition-colors -mr-1"
|
||||
:class="isDark
|
||||
? 'text-white/70 hover:bg-white/10'
|
||||
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
|
||||
title="Clear content"
|
||||
aria-label="Clear content"
|
||||
@click="closePanel"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</PodcastGrid>
|
||||
</div>
|
||||
|
||||
<ContextLoader
|
||||
v-else-if="chatStore.isStreaming"
|
||||
key="loader"
|
||||
:context-type="loaderContextType"
|
||||
/>
|
||||
>
|
||||
<template #header-actions>
|
||||
<button
|
||||
class="p-2 rounded-lg transition-colors -mr-1"
|
||||
:class="isDark
|
||||
? 'text-white/70 hover:bg-white/10'
|
||||
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
|
||||
title="Clear content"
|
||||
aria-label="Clear content"
|
||||
@click="closePanel"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</ContextLoader>
|
||||
|
||||
<div v-else key="empty" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center space-y-4 animate-fade-up max-w-sm px-6">
|
||||
<div class="w-20 h-20 rounded-2xl path-glass-card flex items-center justify-center mx-auto overflow-hidden">
|
||||
<img src="/icon.svg" alt="AIUI" class="w-12 h-12 object-contain" />
|
||||
<div class="empty-state-icon w-20 h-20 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
|
||||
<span class="text-3xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold mb-1"
|
||||
@@ -64,7 +153,7 @@
|
||||
</h2>
|
||||
<p class="text-sm leading-relaxed"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
Ask about films or songs in the chat to see rich content here.
|
||||
Ask about films, songs, or podcasts in the chat to see rich content here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,6 +189,8 @@ import FilmGrid from '@/components/content/FilmGrid.vue'
|
||||
import FilmDetail from '@/components/content/FilmDetail.vue'
|
||||
import SongGrid from '@/components/content/SongGrid.vue'
|
||||
import SongDetail from '@/components/content/SongDetail.vue'
|
||||
import PodcastGrid from '@/components/content/PodcastGrid.vue'
|
||||
import PodcastDetail from '@/components/content/PodcastDetail.vue'
|
||||
import ContextLoader from '@/components/content/ContextLoader.vue'
|
||||
import PlayerBar from '@/components/player/PlayerBar.vue'
|
||||
import { usePlayer } from '@/composables/usePlayer'
|
||||
@@ -114,14 +205,19 @@ const {
|
||||
panelOpen,
|
||||
panelFilms,
|
||||
panelSongs,
|
||||
panelPodcasts,
|
||||
panelTitle,
|
||||
contentType,
|
||||
selectedFilm,
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
openFilmDetail,
|
||||
closeFilmDetail,
|
||||
openSongDetail,
|
||||
closeSongDetail,
|
||||
openPodcastDetail,
|
||||
closePodcastDetail,
|
||||
closePanel,
|
||||
} = useContentPanel()
|
||||
|
||||
const panelSide = computed(() => chatStore.panelSide)
|
||||
@@ -132,6 +228,7 @@ const loaderContextType = computed(() => {
|
||||
const lastUser = [...chatStore.messages].reverse().find((m) => m.role === 'user')
|
||||
const q = (lastUser?.content ?? '').toLowerCase()
|
||||
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
|
||||
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
|
||||
return contentType.value
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -644,6 +644,38 @@ input:focus-visible {
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Empty state icon — app icon in glassmorphic square, white, path-glass border */
|
||||
.empty-state-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Frosted glass center behind the icon */
|
||||
.empty-state-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 15%;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-state-icon img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.light .empty-state-icon::before {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.light .empty-state-icon img {
|
||||
filter: brightness(0) saturate(100%);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
open: true,
|
||||
open: false,
|
||||
proxy: {
|
||||
'/api/claude': {
|
||||
target: 'http://localhost:3141',
|
||||
|
||||
@@ -52,3 +52,28 @@ export interface Song {
|
||||
genres?: string[]
|
||||
sources?: SongSource[]
|
||||
}
|
||||
|
||||
export interface PodcastSource {
|
||||
type: 'fountain' | 'rumble' | 'youtube' | 'podcastindex' | 'castopod' | 'odysee' | 'podverse' | 'ipfs' | 'rss'
|
||||
name: string
|
||||
url: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface Podcast {
|
||||
id: string
|
||||
title: string
|
||||
host?: string
|
||||
description?: string
|
||||
coverUrl?: string
|
||||
year?: number
|
||||
episodeCount?: number
|
||||
genres?: string[]
|
||||
sources: PodcastSource[]
|
||||
}
|
||||
|
||||
export interface PodcastRendererData {
|
||||
podcasts: Podcast[]
|
||||
query?: string
|
||||
totalResults?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user