From 49ec6c09b6cf2c32ed5c66868ac92d8acb35b16c Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 2 Mar 2026 19:57:44 +0000 Subject: [PATCH] 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 --- packages/app/dev-dist/sw.js | 2 +- .../app/src/components/chat/ChatHeader.vue | 5 +- .../app/src/components/chat/ChatMessage.vue | 38 +++- .../app/src/components/chat/ChatWindow.vue | 4 +- .../src/components/content/ContextLoader.vue | 18 +- .../app/src/components/content/FilmGrid.vue | 11 +- .../src/components/content/PodcastCard.vue | 73 ++++++++ .../src/components/content/PodcastDetail.vue | 164 +++++++++++++++++ .../src/components/content/PodcastGrid.vue | 168 ++++++++++++++++++ .../app/src/components/content/SongGrid.vue | 11 +- packages/app/src/composables/useAI.ts | 17 +- .../app/src/composables/useContentPanel.ts | 123 ++++++++++++- .../app/src/composables/useImageFallback.ts | 13 ++ packages/app/src/mocks/podcasts.ts | 108 +++++++++++ packages/app/src/pages/ChatPage.vue | 115 +++++++++++- packages/app/src/styles/main.css | 32 ++++ packages/app/vite.config.ts | 2 +- packages/core/src/types/content.ts | 25 +++ 18 files changed, 890 insertions(+), 39 deletions(-) create mode 100644 packages/app/src/components/content/PodcastCard.vue create mode 100644 packages/app/src/components/content/PodcastDetail.vue create mode 100644 packages/app/src/components/content/PodcastGrid.vue create mode 100644 packages/app/src/mocks/podcasts.ts diff --git a/packages/app/dev-dist/sw.js b/packages/app/dev-dist/sw.js index 2f08870b..fb4065c4 100644 --- a/packages/app/dev-dist/sw.js +++ b/packages/app/dev-dist/sw.js @@ -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"), { diff --git a/packages/app/src/components/chat/ChatHeader.vue b/packages/app/src/components/chat/ChatHeader.vue index a3d904b2..ed496c59 100644 --- a/packages/app/src/components/chat/ChatHeader.vue +++ b/packages/app/src/components/chat/ChatHeader.vue @@ -5,9 +5,8 @@ :class="isDark ? '!border-b-white/10' : '!border-b-black/8'" >
-
- AI +
+
+
+ +
+
{{ formattedTime }} @@ -47,6 +56,13 @@ > View all {{ inlineSongs.length }} songs → +
@@ -55,11 +71,12 @@ diff --git a/packages/app/src/components/content/FilmGrid.vue b/packages/app/src/components/content/FilmGrid.vue index 29138e31..97260113 100644 --- a/packages/app/src/components/content/FilmGrid.vue +++ b/packages/app/src/components/content/FilmGrid.vue @@ -4,13 +4,16 @@ ? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)' : 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'" > -
+

{{ title }}

- - {{ filteredFilms.length }} films - +
+ + {{ filteredFilms.length }} films + + +
+ + + + diff --git a/packages/app/src/components/content/PodcastDetail.vue b/packages/app/src/components/content/PodcastDetail.vue new file mode 100644 index 00000000..1b23c500 --- /dev/null +++ b/packages/app/src/components/content/PodcastDetail.vue @@ -0,0 +1,164 @@ + + + diff --git a/packages/app/src/components/content/PodcastGrid.vue b/packages/app/src/components/content/PodcastGrid.vue new file mode 100644 index 00000000..69254b30 --- /dev/null +++ b/packages/app/src/components/content/PodcastGrid.vue @@ -0,0 +1,168 @@ + + + diff --git a/packages/app/src/components/content/SongGrid.vue b/packages/app/src/components/content/SongGrid.vue index 1cf37334..6f52789e 100644 --- a/packages/app/src/components/content/SongGrid.vue +++ b/packages/app/src/components/content/SongGrid.vue @@ -4,13 +4,16 @@ ? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)' : 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'" > -
+

{{ title }}

- - {{ filteredSongs.length }} songs - +
+ + {{ filteredSongs.length }} songs + + +
`- [${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 diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts index bc3eb54f..83315426 100644 --- a/packages/app/src/composables/useContentPanel.ts +++ b/packages/app/src/composables/useContentPanel.ts @@ -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([]) const panelSongs = ref([]) +const panelPodcasts = ref([]) const selectedFilm = ref(null) const selectedSong = ref(null) +const selectedPodcast = ref(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() + 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, } } diff --git a/packages/app/src/composables/useImageFallback.ts b/packages/app/src/composables/useImageFallback.ts index 86a39574..47ac42c7 100644 --- a/packages/app/src/composables/useImageFallback.ts +++ b/packages/app/src/composables/useImageFallback.ts @@ -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 = ` + + + + + ${escapeXml(title.length > 14 ? title.slice(0, 12) + '…' : title)} + ${host ? `${escapeXml(host.length > 16 ? host.slice(0, 14) + '…' : host)}` : ''} + ` + 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 = ` diff --git a/packages/app/src/mocks/podcasts.ts b/packages/app/src/mocks/podcasts.ts new file mode 100644 index 00000000..372411f4 --- /dev/null +++ b/packages/app/src/mocks/podcasts.ts @@ -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)) + ) +} diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue index 52cc6a86..a13bb0df 100644 --- a/packages/app/src/pages/ChatPage.vue +++ b/packages/app/src/pages/ChatPage.vue @@ -14,12 +14,26 @@
+
+ + > + + + > + + + + +
+ > + +
-
- AIUI +
+

- 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.

@@ -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 }) diff --git a/packages/app/src/styles/main.css b/packages/app/src/styles/main.css index b32fb8ff..53beb48a 100644 --- a/packages/app/src/styles/main.css +++ b/packages/app/src/styles/main.css @@ -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; } diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 50183fdc..d8eb6732 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -72,7 +72,7 @@ export default defineConfig({ server: { port: 5173, host: true, - open: true, + open: false, proxy: { '/api/claude': { target: 'http://localhost:3141', diff --git a/packages/core/src/types/content.ts b/packages/core/src/types/content.ts index 8de724f0..de021155 100644 --- a/packages/core/src/types/content.ts +++ b/packages/core/src/types/content.ts @@ -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 +}