From e01b661aa2cf30539f75ea36857932ae7484e87e Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Thu, 16 Apr 2026 15:03:10 +0200 Subject: [PATCH] feat: implement podcastIndexService using iTunes API for search and trending podcasts --- backend/src/services/podcastIndexService.ts | 8 +- frontend/src/components/EpisodeCard.tsx | 41 +++-- frontend/src/components/EpisodeDetails.tsx | 136 ++++++++++++++++ frontend/src/components/Navigation.tsx | 38 ++--- frontend/src/pages/AddPodcast.tsx | 60 ++++--- frontend/src/pages/Dashboard.tsx | 112 ++++++++++--- frontend/src/pages/Trending.tsx | 169 +++++++++++++------- 7 files changed, 425 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/EpisodeDetails.tsx diff --git a/backend/src/services/podcastIndexService.ts b/backend/src/services/podcastIndexService.ts index 2f7fd82..ef56222 100644 --- a/backend/src/services/podcastIndexService.ts +++ b/backend/src/services/podcastIndexService.ts @@ -59,14 +59,18 @@ export const podcastIndexService = { query: string, limit: number = 20, ): Promise => { + const term = query.trim(); + if (!term) return []; + const response = await axios.get( 'https://itunes.apple.com/search', { params: { - term: query, + term, media: 'podcast', entity: 'podcast', - limit: Math.min(limit, 50), + country: 'FR', // Prioritize French store + limit: Math.max(limit, 50), // Get more results to find the best match }, timeout: 10000, } diff --git a/frontend/src/components/EpisodeCard.tsx b/frontend/src/components/EpisodeCard.tsx index f957608..1429a88 100644 --- a/frontend/src/components/EpisodeCard.tsx +++ b/frontend/src/components/EpisodeCard.tsx @@ -1,12 +1,14 @@ +import React from 'react'; import { Episode } from '../services/episodeService'; -import { Play, Clock, Calendar } from 'lucide-react'; +import { Play, Calendar } from 'lucide-react'; interface EpisodeCardProps { episode: Episode; onPlay: (episode: Episode) => void; + onDetails?: (episode: Episode) => void; } -export default function EpisodeCard({ episode, onPlay }: EpisodeCardProps) { +export default function EpisodeCard({ episode, onPlay, onDetails }: EpisodeCardProps) { const podcast = typeof episode.podcastId === 'object' ? episode.podcastId : null; const pubDate = new Date(episode.pubDate).toLocaleDateString('fr-FR', { month: 'short', @@ -16,13 +18,25 @@ export default function EpisodeCard({ episode, onPlay }: EpisodeCardProps) { const durationMinutes = Math.floor(episode.duration / 60); + const handleDetailsClick = (e: React.MouseEvent) => { + if (onDetails) { + e.stopPropagation(); + onDetails(episode); + } + }; + + const handlePlayClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onPlay(episode); + }; + return (
onPlay(episode)} + className="group premium-glass rounded-[2rem] p-4 hover:bg-[var(--bg-secondary)] transition-all duration-500 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/30 flex flex-col h-full cursor-pointer relative" + onClick={handleDetailsClick} > {/* Visual Header */} -
+
{(episode.imageUrl || podcast?.imageUrl) ? ( ) : ( -
🎙️
+
🎙️
)} {/* Hover Play Button Overlay */} -
-
+
+
@@ -49,17 +66,17 @@ export default function EpisodeCard({ episode, onPlay }: EpisodeCardProps) { {/* Content */}
- + {podcast?.author || 'Artiste Premium'}
-

+

{episode.title}

{/* Footer Meta */} -
+
{pubDate} @@ -71,7 +88,7 @@ export default function EpisodeCard({ episode, onPlay }: EpisodeCardProps) { {episode.progress && !episode.progress.isCompleted && (
diff --git a/frontend/src/components/EpisodeDetails.tsx b/frontend/src/components/EpisodeDetails.tsx new file mode 100644 index 0000000..a5b2e0a --- /dev/null +++ b/frontend/src/components/EpisodeDetails.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { X, Play, Clock, Calendar, BarChart2 } from 'lucide-react'; +import { Episode } from '../services/episodeService'; + +interface EpisodeDetailsProps { + episode: Episode | null; + isOpen: boolean; + onClose: () => void; + onPlay: (episode: Episode) => void; +} + +export default function EpisodeDetails({ episode, isOpen, onClose, onPlay }: EpisodeDetailsProps) { + if (!episode) return null; + + const podcast = typeof episode.podcastId === 'object' ? episode.podcastId : null; + const durationMinutes = Math.floor(episode.duration / 60); + const pubDate = new Date(episode.pubDate).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + + return ( + <> + {/* Backdrop */} +
+ + {/* Slide-over Panel */} +
+ + {/* Header Image Area */} +
+ {episode.imageUrl || podcast?.imageUrl ? ( + {episode.title} + ) : ( +
+ )} +
+ + + +
+ + Episode Détails + +

{episode.title}

+
+
+ + {/* Content */} +
+ {/* Meta Info */} +
+
+
+ +
+
+

Durée

+

{durationMinutes} minutes

+
+
+
+
+ +
+
+

Publié le

+

{pubDate}

+
+
+ {episode.progress && ( +
+
+ +
+
+

Progression

+

{Math.floor((episode.progress.position / episode.duration) * 100)}% écouté

+
+
+ )} +
+ + {/* Action Bar */} +
+ +
+ + {/* Description */} +
+

À propos de cet épisode

+
+
+ + {/* Podcast Info */} + {podcast && ( +
+
+
+ {podcast.title} +
+
+

Disponible sur le podcast

+

{podcast.title}

+

{podcast.author}

+
+
+
+ )} +
+
+ + ); +} diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index 4299b33..e818324 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'react-router-dom'; -import { Home, Flame, PlusCircle, BookOpen, Library } from 'lucide-react'; +import { Home, Flame, PlusCircle, Library } from 'lucide-react'; export default function Navigation() { const location = useLocation(); @@ -7,24 +7,24 @@ export default function Navigation() { const isActive = (path: string) => location.pathname === path; const navItems = [ - { path: '/dashboard', label: 'Tableau de bord', icon: Home, color: 'text-accent-indigo' }, - { path: '/trending', label: 'Tendances', icon: Flame, color: 'text-accent-rose' }, - { path: '/library', label: 'Bibliothèque', icon: Library, color: 'text-accent-cyan' }, - { path: '/add', label: 'Ajouter', icon: PlusCircle, color: 'text-accent-violet' }, + { path: '/dashboard', label: 'Dashboard', icon: Home }, + { path: '/trending', label: 'Trending', icon: Flame }, + { path: '/library', label: 'Library', icon: Library }, + { path: '/add', label: 'Add Podcast', icon: PlusCircle }, ]; return ( <> {/* Desktop Sidebar */} -
- {episodesData?.episodes?.length || 0} EPISODES + {episodes.length} CHARGÉS
- {episodesLoading ? ( + {episodesLoading && skip === 0 ? (
{[1, 2, 3, 4].map(i => (
))}
- ) : episodesData?.episodes && episodesData.episodes.length > 0 ? ( -
- {episodesData.episodes.map((episode) => ( - - ))} -
+ ) : episodes.length > 0 ? ( + <> +
+ {episodes.map((episode) => ( + + ))} +
+ + {hasMore && ( +
+ +
+ )} + ) : (
🧘
@@ -158,11 +212,19 @@ export default function Dashboard() {

Vous n'avez pas encore d'épisodes ici. Abonnez-vous à vos podcasts favoris pour commencer votre collection.

- + Explorer les podcasts
)} + {/* Episode Slide-over Details */} + setIsDetailsOpen(false)} + onPlay={setSelectedEpisode} + /> + {/* Audio Player Management */} {selectedEpisode && ( discoveryService.getTrendingPodcasts(30, selectedGenre), - staleTime: 30 * 60 * 1000, - }); - const { data: subsData } = useQuery({ queryKey: ['podcasts', 'subscriptions'], queryFn: () => podcastService.getUserSubscriptions(), }); + const [skip, setSkip] = useState(0); + const [podcasts, setPodcasts] = useState([]); + const [hasMore, setHasMore] = useState(true); + const LIMIT = 30; + + const { data: trendingData, isLoading, isFetching } = useQuery({ + queryKey: ['discover', 'trending', selectedGenre, skip], + queryFn: () => discoveryService.getTrendingPodcasts(LIMIT, selectedGenre), + staleTime: 30 * 60 * 1000, + }); + + useEffect(() => { + if (trendingData?.podcasts) { + if (skip === 0) { + setPodcasts(trendingData.podcasts); + } else { + setPodcasts(prev => { + const newItems = trendingData.podcasts.filter( + (p: any) => !prev.some(old => old.id === p.id) + ); + return [...prev, ...newItems]; + }); + } + setHasMore(trendingData.podcasts.length === LIMIT); + } + }, [trendingData, skip]); + + // Reset skip when genre changes + useEffect(() => { + setSkip(0); + }, [selectedGenre]); + + const loadMore = () => { + if (hasMore && !isFetching) { + setSkip(prev => prev + LIMIT); + } + }; + const isSubscribed = (rssUrl: string) => { return subsData?.podcasts.some(p => p.rssUrl === rssUrl); }; @@ -132,69 +164,84 @@ export default function Trending() {
- {isLoading ? ( + {isLoading && skip === 0 ? (

Analyse des tendances...

- ) : trendingData?.podcasts && trendingData.podcasts.length > 0 ? ( -
- {trendingData.podcasts.map((podcast, index) => ( -
-
- {podcast.imageUrl ? ( - {podcast.title} - ) : ( -
🎙️
- )} -
- Rank #{index + 1} -
-
-
+ ) : podcasts.length > 0 ? ( + <> +
+ {podcasts.map((podcast, index) => ( +
+
+ {podcast.imageUrl ? ( + {podcast.title} + ) : ( +
🎙️
+ )} +
+ Rank #{index + 1} +
+
+
-
-

- {podcast.title} -

- {podcast.author && ( -

{podcast.author}

- )} -

- {podcast.description} -

- - +

+ {podcast.description} +

+ + +
+ ))} +
+ + {hasMore && ( +
+
- ))} -
+ )} + ) : (