Wire up ContentPanel with tab navigation for all existing content types (films, songs, podcasts, news, websites, magazine). Add complete Books pipeline: type definition, extraction (tags + pattern matching), cover fetching from Open Library, BookCard/BookGrid/BookDetail components, inline chat cards, and prompt index badges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
8.2 KiB
Vue
249 lines
8.2 KiB
Vue
<template>
|
|
<div
|
|
class="flex animate-fade-up-fast"
|
|
:class="isUser ? 'justify-end' : 'justify-start'"
|
|
:style="{ animationDelay: `${index * 30}ms` }"
|
|
>
|
|
<div
|
|
class="max-w-[85%] md:max-w-[75%] rounded-2xl px-4 py-3 transition-all duration-300"
|
|
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
|
|
@click="handleBubbleClick"
|
|
>
|
|
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words"
|
|
:class="isDark ? 'text-white/90' : 'text-gray-800'">{{ displayText }}</p>
|
|
|
|
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
|
|
<FilmCard
|
|
v-for="film in inlineFilms"
|
|
:key="film.id"
|
|
:film="film"
|
|
@select="handleFilmSelect"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="inlineBooks.length > 0" class="mt-3 space-y-1" @click.stop>
|
|
<BookCard
|
|
v-for="book in inlineBooks"
|
|
:key="book.id"
|
|
:book="book"
|
|
@select="handleBookSelect"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="inlineSongs.length > 0" class="mt-3 space-y-1" @click.stop>
|
|
<SongCard
|
|
v-for="song in inlineSongs"
|
|
:key="song.id"
|
|
:song="song"
|
|
@select="handleSongSelect"
|
|
/>
|
|
</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 v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
|
|
<NewsCard
|
|
v-for="(link, i) in inlineNewsLinks"
|
|
:key="i"
|
|
:article="link"
|
|
@select-article="handleArticleSelect"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="inlineWebsitesLinks.length > 0" class="mt-3 space-y-1" @click.stop>
|
|
<NewsCard
|
|
v-for="(link, i) in inlineWebsitesLinks"
|
|
:key="`web-${i}`"
|
|
:article="link"
|
|
@select-article="handleWebsiteSelect"
|
|
/>
|
|
</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>
|
|
<button
|
|
v-if="inlineFilms.length > 1"
|
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
|
@click.stop="openPanel"
|
|
>
|
|
View all {{ inlineFilms.length }} films →
|
|
</button>
|
|
<button
|
|
v-else-if="inlineBooks.length > 1"
|
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
|
@click.stop="openPanel"
|
|
>
|
|
View all {{ inlineBooks.length }} books →
|
|
</button>
|
|
<button
|
|
v-else-if="inlineSongs.length > 1"
|
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
|
@click.stop="openPanel"
|
|
>
|
|
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>
|
|
<button
|
|
v-else-if="inlineNewsLinks.length > 1"
|
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
|
@click.stop="openPanel"
|
|
>
|
|
View all {{ inlineNewsLinks.length }} articles →
|
|
</button>
|
|
<button
|
|
v-else-if="inlineWebsitesLinks.length > 1"
|
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
|
@click.stop="openPanel"
|
|
>
|
|
View all {{ inlineWebsitesLinks.length }} websites →
|
|
</button>
|
|
<button
|
|
v-else-if="inlineMagazineSections.length > 0"
|
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
|
@click.stop="openPanel"
|
|
>
|
|
View brief →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { Message, WebSearchResult } from '@aiui/core/types/message'
|
|
import type { Film, Song, Podcast, Book } from '@aiui/core/types/content'
|
|
import { useTheme } from '@/composables/useTheme'
|
|
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
|
|
import { useArticleOverlayStore } from '@/stores/articleOverlay'
|
|
import FilmCard from '@/components/content/FilmCard.vue'
|
|
import BookCard from '@/components/content/BookCard.vue'
|
|
import SongCard from '@/components/content/SongCard.vue'
|
|
import PodcastCard from '@/components/content/PodcastCard.vue'
|
|
import NewsCard from '@/components/content/NewsCard.vue'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
message: Message
|
|
index: number
|
|
triggeringQuery?: string
|
|
}>(),
|
|
{ triggeringQuery: '' }
|
|
)
|
|
|
|
const { isDark } = useTheme()
|
|
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openBookDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeBookDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
|
|
const overlayStore = useArticleOverlayStore()
|
|
|
|
const isUser = computed(() => props.message.role === 'user')
|
|
|
|
const inlineContent = computed(() => {
|
|
if (isUser.value) return { films: [] as Film[], books: [] as Book[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
|
|
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
|
|
})
|
|
|
|
const bubbleClasses = computed(() =>
|
|
isUser.value
|
|
? 'path-glass-bubble-user rounded-br-md rounded-2xl'
|
|
: 'path-glass-bubble rounded-bl-md rounded-2xl'
|
|
)
|
|
|
|
const inlineFilms = computed(() => inlineContent.value.films)
|
|
const inlineBooks = computed(() => inlineContent.value.books ?? [])
|
|
const inlineSongs = computed(() => inlineContent.value.songs)
|
|
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
|
|
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
|
|
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
|
|
const inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? [])
|
|
|
|
const hasContext = computed(() => !isUser.value && (
|
|
inlineFilms.value.length > 0 ||
|
|
inlineBooks.value.length > 0 ||
|
|
inlineSongs.value.length > 0 ||
|
|
inlinePodcasts.value.length > 0 ||
|
|
inlineNewsLinks.value.length > 0 ||
|
|
inlineWebsitesLinks.value.length > 0 ||
|
|
inlineMagazineSections.value.length > 0
|
|
))
|
|
|
|
const displayText = computed(() => {
|
|
if (isUser.value) return props.message.content
|
|
let text = stripContentTags(props.message.content)
|
|
if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text)
|
|
return text
|
|
})
|
|
|
|
const formattedTime = computed(() => {
|
|
const d = new Date(props.message.timestamp)
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
})
|
|
|
|
function openPanelWithContext() {
|
|
updatePanelFromText(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
|
|
}
|
|
|
|
function handleFilmSelect(film: Film) {
|
|
openPanelWithContext()
|
|
closeSongDetail()
|
|
closePodcastDetail()
|
|
openFilmDetail(film)
|
|
}
|
|
|
|
function handleBookSelect(book: Book) {
|
|
openPanelWithContext()
|
|
closeFilmDetail()
|
|
closeSongDetail()
|
|
closePodcastDetail()
|
|
openBookDetail(book)
|
|
}
|
|
|
|
function handleSongSelect(song: Song) {
|
|
openPanelWithContext()
|
|
closeFilmDetail()
|
|
closeBookDetail()
|
|
closePodcastDetail()
|
|
openSongDetail(song)
|
|
}
|
|
|
|
function handlePodcastSelect(podcast: Podcast) {
|
|
openPanelWithContext()
|
|
closeFilmDetail()
|
|
closeSongDetail()
|
|
openPodcastDetail(podcast)
|
|
}
|
|
|
|
function openPanel() {
|
|
closeFilmDetail()
|
|
closeSongDetail()
|
|
closePodcastDetail()
|
|
openPanelWithContext()
|
|
}
|
|
|
|
function handleArticleSelect(article: WebSearchResult) {
|
|
openPanelWithContext()
|
|
openArticleDetail(article)
|
|
}
|
|
|
|
function handleWebsiteSelect(article: WebSearchResult) {
|
|
overlayStore.open(article.url, article.title, undefined, article.imgSrc)
|
|
}
|
|
|
|
function handleBubbleClick() {
|
|
if (hasContext.value) openPanel()
|
|
}
|
|
</script>
|