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
+120 -3
View File
@@ -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,
}
}