feat: implement trending podcasts discovery page with genre filtering and subscription capabilities

This commit is contained in:
Michael committed 2026-04-16 15:27:00 +02:00
1 parent 67d45b72ee
commit c9127c70c1
6 files changed
+203 -19

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { X, AlertCircle, Info, CheckCircle2, AlertTriangle } from 'lucide-react';
export type AlertType = 'error' | 'info' | 'success' | 'warning';
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
type?: AlertType;
}
export default function AlertModal({
isOpen,
onClose,
title,
message,
type = 'error'
}: AlertModalProps) {
if (!isOpen) return null;
const config = {
error: {
icon: AlertCircle,
color: 'text-accent-rose',
bg: 'bg-accent-rose/10',
border: 'border-accent-rose/20',
glow: 'shadow-[0_0_30px_rgba(244,63,94,0.3)]'
},
warning: {
icon: AlertTriangle,
color: 'text-amber-500',
bg: 'bg-amber-500/10',
border: 'border-amber-500/20',
glow: 'shadow-[0_0_30px_rgba(245,158,11,0.3)]'
},
success: {
icon: CheckCircle2,
color: 'text-accent-cyan',
bg: 'bg-accent-cyan/10',
border: 'border-accent-cyan/20',
glow: 'shadow-[0_0_30_rgba(6,182,212,0.3)]'
},
info: {
icon: Info,
color: 'text-accent-indigo',
bg: 'bg-accent-indigo/10',
border: 'border-accent-indigo/20',
glow: 'shadow-[0_0_30_rgba(99,102,241,0.3)]'
}
};
const { icon: StatusIcon, color, bg, border, glow } = config[type];
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-md animate-fade-in"
onClick={onClose}
/>
{/* Modal Content */}
<div className={`relative premium-glass rounded-[var(--radius-panel)] w-full max-w-sm overflow-hidden animate-slide-up border-[var(--border-color)] ${glow}`}>
<div className="p-8 flex flex-col items-center text-center">
{/* Header Actions */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Icon */}
<div className={`w-16 h-16 ${bg} ${border} border rounded-2xl flex items-center justify-center mb-6`}>
<StatusIcon className={`w-8 h-8 ${color}`} />
</div>
<h3 className="text-xl font-display font-black mb-3">
{title}
</h3>
<p className="text-[var(--text-secondary)] text-sm leading-relaxed mb-8">
{message}
</p>
<button
onClick={onClose}
className="w-full py-4 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] text-xs font-black uppercase tracking-widest hover:bg-[var(--accent-primary)]/10 hover:text-[var(--text-primary)] transition-all border border-[var(--border-color)]"
>
Fermer
</button>
</div>
</div>
</div>
);
}
+4 -4
View File
@@ -7,10 +7,10 @@ export default function Navigation() {
const isActive = (path: string) => location.pathname === path;
const navItems = [
{ 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 },
{ path: '/dashboard', label: 'Tableau de bord', icon: Home },
{ path: '/trending', label: 'Tendances', icon: Flame },
{ path: '/library', label: 'Bibliothèque', icon: Library },
{ path: '/add', label: 'Ajouter', icon: PlusCircle },
];
return (
+31 -6
View File
@@ -7,6 +7,7 @@ import Header from '../components/Header';
import EpisodeCard from '../components/EpisodeCard';
import EpisodeDetails from '../components/EpisodeDetails';
import AudioPlayer from '../components/AudioPlayer';
import AlertModal, { AlertType } from '../components/AlertModal';
import { Search, Plus, Loader, Rss, Check, Globe, Sparkles, PlusCircle, LayoutGrid, ListMusic } from 'lucide-react';
import { authService } from '../services/authService';
import SuccessModal from '../components/SuccessModal';
@@ -19,7 +20,20 @@ export default function AddPodcast() {
const [subscribingId, setSubscribingId] = useState<string | null>(null);
const [rssUrl, setRssUrl] = useState('');
const [rssLoading, setRssLoading] = useState(false);
const [rssError, setRssError] = useState<string | null>(null);
// Alert states
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean;
title: string;
message: string;
type: AlertType;
}>({
isOpen: false,
title: '',
message: '',
type: 'error'
});
const [rssSuccess, setRssSuccess] = useState<string | null>(null);
const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(null);
const [detailsEpisode, setDetailsEpisode] = useState<Episode | null>(null);
@@ -101,7 +115,6 @@ export default function AddPodcast() {
e.preventDefault();
if (!rssUrl.trim()) return;
setRssLoading(true);
setRssError(null);
setRssSuccess(null);
try {
const result = await podcastService.subscribe(rssUrl.trim());
@@ -109,7 +122,12 @@ export default function AddPodcast() {
setRssUrl('');
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
} catch (error: any) {
setRssError(error.response?.data?.message || 'Impossible d\'ajouter ce podcast');
setAlertConfig({
isOpen: true,
title: "Échec de l'ajout",
message: error.response?.data?.message || "Impossible d'ajouter ce podcast. Vérifiez que l'URL du flux RSS est correcte et accessible.",
type: 'error'
});
} finally {
setRssLoading(false);
}
@@ -141,7 +159,7 @@ export default function AddPodcast() {
type="url"
placeholder="URL du flux RSS (ex: https://feed.podbean.com/...)"
value={rssUrl}
onChange={(e) => { setRssUrl(e.target.value); setRssError(null); setRssSuccess(null); }}
onChange={(e) => { setRssUrl(e.target.value); setRssSuccess(null); }}
className="input-premium flex-1"
/>
<button
@@ -154,8 +172,7 @@ export default function AddPodcast() {
</button>
</form>
{rssError && <p className="mt-4 text-xs font-bold text-accent-rose">⚠️ {rssError}</p>}
{rssSuccess && <p className="mt-4 text-xs font-bold text-accent-cyan">✨ {rssSuccess}</p>}
{rssSuccess && <p className="mt-4 text-xs font-bold text-accent-cyan animate-fade-in">✨ {rssSuccess}</p>}
</div>
</div>
@@ -270,6 +287,14 @@ export default function AddPodcast() {
onPlay={setSelectedEpisode}
/>
<AlertModal
isOpen={alertConfig.isOpen}
title={alertConfig.title}
message={alertConfig.message}
type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))}
/>
{selectedEpisode && (
<AudioPlayer
episode={selectedEpisode}
+33 -6
View File
@@ -5,11 +5,25 @@ import { authService } from '../services/authService';
import Header from '../components/Header';
import { BookOpen, Trash2, Loader, Rss, Play, RefreshCw, AlertCircle } from 'lucide-react';
import { Link } from 'react-router-dom';
import AlertModal, { AlertType } from '../components/AlertModal';
export default function Library() {
const queryClient = useQueryClient();
const [user, setUser] = useState<any>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
// Alert states
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean;
title: string;
message: string;
type: AlertType;
}>({
isOpen: false,
title: '',
message: '',
type: 'error'
});
useEffect(() => {
const loadUser = async () => {
@@ -51,7 +65,12 @@ export default function Library() {
},
onError: (error: any) => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
alert('Erreur lors du désabonnement');
setAlertConfig({
isOpen: true,
title: "Erreur de désabonnement",
message: "Une erreur est survenue lors de la tentative de désabonnement. Veuillez réessayer plus tard.",
type: 'error'
});
},
});
@@ -92,7 +111,7 @@ export default function Library() {
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-10">
<div className="premium-glass p-5 rounded-[1.5rem] flex items-center gap-4">
<div className="premium-glass p-5 rounded-[var(--radius-card)] flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-glow)] flex items-center justify-center text-[var(--accent-primary)] shrink-0">
<BookOpen className="w-5 h-5" />
</div>
@@ -101,7 +120,7 @@ export default function Library() {
<h3 className="text-2xl font-display font-black">{podcasts.length}</h3>
</div>
</div>
<Link to="/add" className="premium-glass p-5 rounded-[1.5rem] flex items-center gap-4 group hover:bg-[var(--accent-primary)]/5 transition-all">
<Link to="/add" className="premium-glass p-5 rounded-[var(--radius-card)] flex items-center gap-4 group hover:bg-[var(--accent-primary)]/5 transition-all">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-primary)] flex items-center justify-center text-white shadow-glow-indigo group-hover:scale-110 transition-transform shrink-0">
<Rss className="w-5 h-5" />
</div>
@@ -118,8 +137,8 @@ export default function Library() {
<p className="text-[var(--text-secondary)] font-medium animate-pulse text-[10px] uppercase tracking-widest">Chargement de votre univers...</p>
</div>
) : podcasts.length === 0 ? (
<div className="premium-glass py-24 px-12 rounded-[3.5rem] text-center max-w-2xl mx-auto">
<div className="w-24 h-24 rounded-[2rem] bg-[var(--bg-secondary)] flex items-center justify-center text-4xl mx-auto mb-8 border border-[var(--border-color)]">🕳️</div>
<div className="premium-glass py-24 px-12 rounded-[var(--radius-panel)] text-center max-w-2xl mx-auto">
<div className="w-24 h-24 rounded-[var(--radius-card)] bg-[var(--bg-secondary)] flex items-center justify-center text-4xl mx-auto mb-8 border border-[var(--border-color)]">🕳️</div>
<h2 className="text-3xl font-display font-black mb-4">Bibliothèque vide</h2>
<p className="text-[var(--text-secondary)] mb-10 leading-relaxed">Commencez votre voyage audio en ajoutant vos podcasts préférés dès maintenant.</p>
<Link to="/add" className="neon-button inline-flex items-center gap-3">
@@ -130,7 +149,7 @@ export default function Library() {
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8 pb-10">
{podcasts.map((podcast) => (
<div key={podcast._id} className="group premium-glass rounded-[2.5rem] p-6 hover:bg-[var(--bg-secondary)] transition-all duration-500 flex flex-col h-full border-[var(--border-color)]">
<div key={podcast._id} className="group premium-glass rounded-[var(--radius-card)] p-6 hover:bg-[var(--bg-secondary)] transition-all duration-500 flex flex-col h-full border-[var(--border-color)]">
<div className="flex gap-6 mb-6">
<div className="relative shrink-0">
<div className="w-24 h-24 rounded-3xl overflow-hidden shadow-2xl border border-[var(--border-color)] group-hover:scale-105 transition-transform duration-500">
@@ -219,6 +238,14 @@ export default function Library() {
))}
</div>
)}
<AlertModal
isOpen={alertConfig.isOpen}
title={alertConfig.title}
message={alertConfig.message}
type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))}
/>
</div>
);
}
+37 -1
View File
@@ -6,6 +6,7 @@ import Header from '../components/Header';
import { Loader, Plus, Flame, Check, Sparkles, TrendingUp } from 'lucide-react';
import { authService } from '../services/authService';
import SuccessModal from '../components/SuccessModal';
import AlertModal, { AlertType } from '../components/AlertModal';
export default function Trending() {
const queryClient = useQueryClient();
@@ -17,6 +18,19 @@ export default function Trending() {
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successPodcast, setSuccessPodcast] = useState<any>(null);
// Alert state
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean;
title: string;
message: string;
type: AlertType;
}>({
isOpen: false,
title: '',
message: '',
type: 'error'
});
const GENRES = [
{ id: '', label: 'Tout' },
{ id: '1318', label: 'Technologie' },
@@ -98,7 +112,16 @@ export default function Trending() {
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
onError: (error: any) => {
setAlertConfig({
isOpen: true,
title: "Échec de l'abonnement",
message: error.response?.data?.message || "Une erreur est survenue. Le flux de ce podcast est peut-être temporairement inaccessible.",
type: 'error'
});
}
});
const handleSubscribe = async (podcast: DiscoveryPodcast) => {
@@ -109,7 +132,12 @@ export default function Trending() {
setShowSuccessModal(true);
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
} catch (error: any) {
console.error(error);
setAlertConfig({
isOpen: true,
title: "Action impossible",
message: "Une erreur critique est survenue lors de la tentative d'abonnement.",
type: 'error'
});
} finally {
setSubscribingId(null);
}
@@ -257,6 +285,14 @@ export default function Trending() {
podcastAuthor={successPodcast?.author || ""}
podcastImage={successPodcast?.imageUrl || ""}
/>
<AlertModal
isOpen={alertConfig.isOpen}
title={alertConfig.title}
message={alertConfig.message}
type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))}
/>
</div>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@
--accent-glow: rgba(99, 102, 241, 0.35);
--border-color: rgba(255, 255, 255, 0.07);
--glass-blur: blur(24px) saturate(180%);
--shadow-premium: 0 8px 40px rgba(0, 0, 0, 0.4);
--shadow-premium: 0 25px 60px rgba(0, 0, 0, 0.6), 0 0 1px rgba(255, 255, 255, 0.1);
--input-bg: rgba(255, 255, 255, 0.03);
--radius-card: 1.5rem; /* 24px */
--radius-panel: 2rem; /* 32px */
@@ -31,7 +31,7 @@
--accent-glow: rgba(197, 160, 40, 0.2);
--border-color: rgba(197, 160, 40, 0.15);
--glass-blur: blur(20px) saturate(120%);
--shadow-premium: 0 12px 48px rgba(0, 0, 0, 0.08);
--shadow-premium: 0 20px 50px rgba(197, 160, 40, 0.15), 0 10px 30px rgba(0, 0, 0, 0.06);
--radius-card: 1.5rem;
--radius-panel: 2rem;
--input-bg: #ffffff;