feat: initialize frontend entry point with HTML boilerplate and custom fonts

This commit is contained in:
Michael committed 2026-04-16 14:09:14 +02:00
1 parent f441251bb1
commit 6e9da01488
14 files changed
+872 -907

No files matched your search

+5 -2
View File
@@ -5,8 +5,11 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Podcastic - Stream your favorite podcasts" />
<meta name="theme-color" content="#ffffff" />
<title>Podcastic - Podcast Streaming</title>
<meta name="theme-color" content="#020617" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;800&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Podcastic - Premium Podcast Streaming</title>
</head>
<body>
<div id="root"></div>
+6 -2
View File
@@ -27,9 +27,13 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
}
return (
<div className="flex">
<div className="flex bg-obsidian min-h-screen">
<Navigation />
<div className="flex-1 lg:ml-64">{children}</div>
<main className="flex-1 lg:ml-80 lg:mr-8 pb-32 pt-8 px-4 lg:px-0">
<div className="reveal">
{children}
</div>
</main>
</div>
);
}
+156 -153
View File
@@ -8,6 +8,14 @@ import {
SkipBack,
SkipForward,
ChevronDown,
Maximize2,
Minimize2,
List,
VolumeX,
FastForward,
Rewind,
X,
Info
} from 'lucide-react';
interface AudioPlayerProps {
@@ -24,6 +32,8 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
const [volume, setVolume] = useState(1);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [isSaving, setIsSaving] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null;
@@ -124,172 +134,165 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (!episode) {
return null;
}
if (!episode) return null;
return (
<div
className="fixed inset-0 bg-white z-[100] overflow-y-auto animate-fade-in"
onClick={(e) => {
// Close if clicking the backdrop itself
if (e.target === e.currentTarget) onClose();
}}
className={`fixed bottom-8 left-1/2 -translate-x-1/2 w-[95%] max-w-6xl z-[100] transition-all duration-700 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
isExpanded ? 'h-[80vh] bottom-1/2 translate-y-1/2 rounded-[3rem]' : 'h-24 rounded-[2.5rem]'
} premium-glass p-0 shadow-2xl border-white/10 flex flex-col overflow-hidden animate-slide-up`}
>
<div className="min-h-full gradient-light">
{/* Header */}
<div className="sticky top-0 bg-white/80 backdrop-blur-xl border-b border-light-200">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-light-900">En cours de lecture</h2>
<button
onClick={onClose}
className="p-2 hover:bg-light-100 rounded-lg transition-colors"
>
<ChevronDown className="w-6 h-6" />
</button>
</div>
</div>
{/* Player Content */}
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Artwork */}
<div className="aspect-square max-w-sm mx-auto mb-8 rounded-3xl overflow-hidden shadow-2xl">
{podcast?.imageUrl ? (
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-primary flex items-center justify-center text-white text-9xl">
🎙️
</div>
)}
</div>
<audio
ref={audioRef}
src={episode.audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
crossOrigin="anonymous"
autoPlay
/>
{/* COMPACT VIEW (DOCK) */}
<div className={`flex items-center gap-6 p-4 h-24 shrink-0 transition-opacity duration-300 ${isExpanded ? 'opacity-0 pointer-events-none absolute' : 'opacity-100'}`}>
{/* Info */}
<div className="text-center mb-8">
<p className="text-sm font-semibold text-light-500 uppercase mb-2">
{podcast?.author || 'Podcast inconnu'}
</p>
<h1 className="text-4xl font-bold text-light-900 mb-3">{episode.title}</h1>
<p className="text-light-600 line-clamp-3">{episode.description}</p>
</div>
{/* Progress Bar */}
<div className="mb-6 card">
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleProgressChange}
className="w-full h-2 bg-light-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="flex justify-between text-xs text-light-600 mt-2">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-6 mb-8">
{/* Previous 15s */}
<button
onClick={() => {
if (audioRef.current) {
audioRef.current.currentTime = Math.max(0, currentTime - 15);
}
}}
className="p-3 hover:bg-light-100 rounded-full transition-colors"
title="Reculer de 15s"
>
<SkipBack className="w-6 h-6" />
</button>
{/* Play/Pause */}
<button
onClick={handlePlayPause}
className="w-20 h-20 rounded-full bg-gradient-primary text-white flex items-center justify-center hover:shadow-lg transition-shadow"
>
{isPlaying ? (
<Pause className="w-8 h-8 ml-1" fill="currentColor" />
<div className="flex items-center gap-4 min-w-[200px] lg:min-w-[300px] max-w-[400px]">
<div className="w-16 h-16 rounded-2xl overflow-hidden shadow-lg border border-white/10 shrink-0">
{podcast?.imageUrl ? (
<img src={podcast.imageUrl} alt={podcast.title} className="w-full h-full object-cover" />
) : (
<Play className="w-8 h-8 ml-1" fill="currentColor" />
<div className="w-full h-full bg-gradient-to-br from-accent-indigo to-accent- violet flex items-center justify-center text-2xl">🎙️</div>
)}
</button>
{/* Next 30s */}
<button
onClick={() => {
if (audioRef.current) {
audioRef.current.currentTime = Math.min(duration, currentTime + 30);
}
}}
className="p-3 hover:bg-light-100 rounded-full transition-colors"
title="Avancer de 30s"
>
<SkipForward className="w-6 h-6" />
</button>
</div>
{/* Speed & Volume */}
<div className="grid grid-cols-2 gap-6">
{/* Playback Speed */}
<div className="card">
<h3 className="text-sm font-semibold text-light-900 mb-3">Vitesse</h3>
<div className="flex gap-2">
{[0.75, 1, 1.25, 1.5, 1.75, 2].map((speed) => (
<button
key={speed}
onClick={() => handleSpeedChange(speed)}
className={`flex-1 py-2 rounded-lg font-medium transition-colors ${
playbackSpeed === speed
? 'bg-blue-500 text-white'
: 'bg-light-100 text-light-700 hover:bg-light-200'
}`}
>
{speed}x
</button>
))}
</div>
</div>
{/* Volume */}
<div className="card">
<div className="flex items-center gap-3 mb-3">
<Volume2 className="w-4 h-4 text-light-600" />
<h3 className="text-sm font-semibold text-light-900">Volume</h3>
</div>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-full h-2 bg-light-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="overflow-hidden">
<h4 className="text-sm font-bold text-white truncate leading-tight mb-1">{episode.title}</h4>
<p className="text-[10px] text-slate-400 font-bold truncate uppercase tracking-widest">{podcast?.title || 'Unknown Podcast'}</p>
</div>
</div>
{/* Hidden Audio Element */}
<audio
ref={audioRef}
src={episode.audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
onError={(e) => {
const target = e.target as HTMLAudioElement;
console.error('Audio playback error:', target.error);
alert("Erreur de lecture audio. Le lien est peut-être mort ou bloqué.");
}}
crossOrigin="anonymous"
autoPlay
/>
{/* Playback Controls */}
<div className="flex-1 flex flex-col items-center gap-2">
<div className="flex items-center gap-6">
<button
onClick={() => audioRef.current && (audioRef.current.currentTime -= 15)}
className="text-slate-400 hover:text-white transition-all hover:scale-110 active:scale-90"
>
<Rewind className="w-5 h-5" />
</button>
<button
onClick={handlePlayPause}
className="w-12 h-12 rounded-full bg-white text-obsidian flex items-center justify-center transition-all hover:scale-110 active:scale-95 shadow-glow-indigo hover:shadow-white/50"
>
{isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-0.5" />}
</button>
<button
onClick={() => audioRef.current && (audioRef.current.currentTime += 30)}
className="text-slate-400 hover:text-white transition-all hover:scale-110 active:scale-90"
>
<FastForward className="w-5 h-5" />
</button>
</div>
<div className="w-full max-w-xl flex items-center gap-3 group">
<span className="text-[10px] font-bold text-slate-500 w-10 text-right tabular-nums">{formatTime(currentTime)}</span>
<div className="relative flex-1 h-1.5 flex items-center cursor-pointer">
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleProgressChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<div className="w-full h-1 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-accent-indigo to-accent-cyan shadow-[0_0_10px_rgba(99,102,241,0.5)] transition-all duration-100"
style={{ width: `${(currentTime / duration) * 100}%` }}
/>
</div>
</div>
<span className="text-[10px] font-bold text-slate-500 w-10 tabular-nums">{formatTime(duration)}</span>
</div>
</div>
{/* Sidebar-like utility */}
<div className="flex items-center gap-4 min-w-[200px] justify-end">
<button
onClick={() => setIsExpanded(true)}
className="p-3 rounded-2xl bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/10 transition-all"
>
<Maximize2 className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-3 rounded-2xl bg-white/[0.03] text-slate-400 hover:text-accent-rose hover:bg-accent-rose/10 transition-all"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* EXPANDED VIEW */}
{isExpanded && (
<div className="flex-1 flex flex-col p-12 transition-all duration-500 reveal">
<div className="flex justify-between items-start mb-12">
<button onClick={() => setIsExpanded(false)} className="p-4 rounded-3xl bg-white/5 text-slate-400 hover:text-white transition-all">
<ChevronDown className="w-6 h-6" />
</button>
<div className="text-center">
<span className="text-[11px] font-bold text-accent-indigo uppercase tracking-[0.3em] mb-2 block">Premium Experience</span>
<h2 className="text-xl font-display font-extrabold text-white">Now Playing</h2>
</div>
<button onClick={onClose} className="p-4 rounded-3xl bg-white/5 text-slate-400 hover:text-accent-rose transition-all">
<X className="w-6 h-6" />
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center flex-1">
<div className="aspect-square w-full max-w-md mx-auto rounded-[3.5rem] overflow-hidden shadow-2xl border-4 border-white/10 transform rotate-1">
{podcast?.imageUrl ? (
<img src={podcast.imageUrl} alt={podcast.title} className="w-full h-full object-cover scale-105" />
) : (
<div className="w-full h-full bg-gradient-to-tr from-accent-indigo via-accent-violet to-accent-rose flex items-center justify-center text-9xl">🎙️</div>
)}
</div>
<div className="flex flex-col gap-8">
<div>
<h1 className="text-5xl font-display font-black text-white leading-tight mb-4">{episode.title}</h1>
<p className="text-lg text-slate-400 font-medium leading-relaxed line-clamp-4">{episode.description}</p>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold text-accent-indigo tabular-nums">{formatTime(currentTime)}</span>
<span className="text-xs font-bold text-slate-500 tabular-nums">{formatTime(duration)}</span>
</div>
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleProgressChange}
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer accent-accent-indigo"
/>
</div>
<div className="flex items-center justify-center gap-12">
<button onClick={() => handleSpeedChange(playbackSpeed === 2 ? 1 : playbackSpeed + 0.5)} className="text-xs font-bold text-slate-400 hover:text-white">
SPEED {playbackSpeed}x
</button>
<button onClick={handlePlayPause} className="w-24 h-24 rounded-full bg-white text-obsidian flex items-center justify-center shadow-glow-indigo transform active:scale-90 transition-transform">
{isPlaying ? <Pause className="w-10 h-10 fill-current" /> : <Play className="w-10 h-10 fill-current ml-2" />}
</button>
<div className="flex items-center gap-4">
<Volume2 className="w-5 h-5 text-slate-400" />
<input type="range" min="0" max="1" step="0.1" value={volume} onChange={handleVolumeChange} className="w-32 h-1 bg-white/10 rounded-full accent-white" />
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+44 -37
View File
@@ -1,5 +1,5 @@
import { Episode } from '../services/episodeService';
import { Play } from 'lucide-react';
import { Play, Clock, Calendar } from 'lucide-react';
interface EpisodeCardProps {
episode: Episode;
@@ -11,52 +11,59 @@ export default function EpisodeCard({ episode, onPlay }: EpisodeCardProps) {
const pubDate = new Date(episode.pubDate).toLocaleDateString('fr-FR', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
const durationMinutes = Math.floor(episode.duration / 60);
return (
<div className="group cursor-pointer">
<div className="relative rounded-2xl overflow-hidden mb-3 glossy">
{/* Podcast Image */}
<div className="aspect-square bg-light-200 flex items-center justify-center relative overflow-hidden">
{podcast?.imageUrl ? (
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-primary flex items-center justify-center text-white text-4xl">
🎙️
</div>
)}
{/* Play Button Overlay */}
<button
onClick={() => onPlay(episode)}
className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-all duration-300"
>
<div className="w-16 h-16 rounded-full bg-white/90 group-hover:bg-white flex items-center justify-center shadow-lg transform group-hover:scale-110 transition-transform">
<Play className="w-6 h-6 text-blue-500 ml-1" fill="currentColor" />
</div>
</button>
<div
className="group premium-glass rounded-[2rem] p-4 hover:bg-white/5 transition-all duration-500 border-white/5 hover:border-white/10 flex flex-col h-full cursor-pointer"
onClick={() => onPlay(episode)}
>
{/* Visual Header */}
<div className="relative aspect-square rounded-2xl overflow-hidden mb-5 shadow-xl border border-white/5">
{podcast?.imageUrl ? (
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-accent-indigo to-accent-rose flex items-center justify-center text-4xl">🎙️</div>
)}
{/* Hover Play Button Overlay */}
<div className="absolute inset-0 bg-obsidian/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center backdrop-blur-[2px]">
<div className="w-16 h-16 rounded-full bg-white text-obsidian flex items-center justify-center shadow-glow-indigo transform scale-90 group-hover:scale-100 transition-transform duration-500">
<Play className="w-6 h-6 fill-current ml-1" />
</div>
</div>
{/* Duration Badge */}
<div className="absolute bottom-3 right-3 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-md border border-white/10 text-[9px] font-black text-white uppercase tracking-widest">
{durationMinutes} MIN
</div>
</div>
{/* Podcast Title */}
<p className="text-xs font-semibold text-light-500 uppercase tracking-wider mb-1">
{podcast?.author || 'Inconnu'}
</p>
{/* Episode Title */}
<h3 className="text-sm font-bold text-light-900 line-clamp-2 mb-2 group-hover:text-blue-500 transition-colors">
{/* Content */}
<div className="flex-1 flex flex-col">
<div className="flex items-center gap-2 mb-2">
<span className="text-[9px] font-black text-accent-indigo uppercase tracking-[0.2em] truncate flex-1">
{podcast?.author || 'Artiste Premium'}
</span>
</div>
<h3 className="text-sm font-bold text-white line-clamp-2 mb-4 leading-relaxed group-hover:text-accent-indigo transition-colors flex-1">
{episode.title}
</h3>
{/* Episode Meta */}
<div className="flex items-center justify-between text-xs text-light-500">
<span>{pubDate}</span>
<span>{durationMinutes} min</span>
{/* Footer Meta */}
<div className="flex items-center justify-between pt-4 border-t border-white/5 text-[10px] font-bold text-slate-500 uppercase tracking-widest">
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
<span>{pubDate}</span>
</div>
</div>
</div>
</div>
+42 -42
View File
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { LogOut, User as UserIcon } from 'lucide-react';
interface HeaderProps {
title: string;
@@ -9,50 +10,49 @@ interface HeaderProps {
export default function Header({ title, subtitle, user, onLogout }: HeaderProps) {
return (
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur-xl border-b border-light-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
{/* Logo & Title */}
<div className="flex-1">
<Link to="/" className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-primary flex items-center justify-center text-white font-bold">
🎙️
</div>
<div>
<h1 className="text-2xl font-bold text-light-900">{title}</h1>
{subtitle && <p className="text-sm text-light-500">{subtitle}</p>}
</div>
</Link>
<header className="mb-12">
<div className="flex items-center justify-between">
{/* Title Section */}
<div className="flex-1">
<div className="inline-block px-3 py-1 rounded-full bg-accent-indigo/10 border border-accent-indigo/20 text-[10px] font-bold text-accent-indigo uppercase tracking-widest mb-3">
{subtitle || "Exploration Audio"}
</div>
{/* User Menu */}
{user && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{user.avatar ? (
<img
src={user.avatar}
alt={user.username}
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-light-300 flex items-center justify-center text-sm font-semibold text-light-700">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="text-sm font-medium text-light-900">{user.username}</span>
</div>
{onLogout && (
<button
onClick={onLogout}
className="px-4 py-2 text-sm text-light-600 hover:text-light-900 transition-colors"
>
Déconnexion
</button>
)}
</div>
)}
<h1 className="text-4xl lg:text-5xl font-display font-extrabold text-white tracking-tight text-balance leading-tight">
{title}
</h1>
</div>
{/* User Profile */}
{user && (
<div className="flex items-center gap-6">
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm font-bold text-white tracking-wide">{user.username}</span>
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-widest">Premium Member</span>
</div>
<div className="relative group">
<div className="w-12 h-12 rounded-2xl p-[1px] bg-gradient-to-tr from-accent-indigo/50 to-accent-rose/50 group-hover:from-accent-indigo group-hover:to-accent-rose transition-all">
<div className="w-full h-full rounded-2xl bg-obsidian-800 flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt={user.username} className="w-full h-full object-cover" />
) : (
<span className="text-lg font-bold text-accent-indigo">{user.username.charAt(0).toUpperCase()}</span>
)}
</div>
</div>
{/* Profile Popup (Minimal) */}
{onLogout && (
<button
onClick={onLogout}
className="absolute top-full right-0 mt-4 opacity-0 scale-90 translate-y-2 group-hover:opacity-100 group-hover:scale-100 group-hover:translate-y-0 transition-all duration-300 premium-glass px-4 py-2 flex items-center gap-2 hover:bg-white/10"
>
<LogOut className="w-4 h-4 text-accent-rose" />
<span className="text-xs font-bold text-white whitespace-nowrap">Logout</span>
</button>
)}
</div>
</div>
)}
</div>
</header>
);
+76 -64
View File
@@ -1,78 +1,90 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Flame, PlusCircle, BookOpen } from 'lucide-react';
import { Home, Flame, PlusCircle, BookOpen, Music2, Search, Library } from 'lucide-react';
export default function Navigation() {
const location = useLocation();
const isActive = (path: string) => location.pathname === path;
const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: Home, color: 'text-accent-indigo' },
{ path: '/trending', label: 'Trending', icon: Flame, color: 'text-accent-rose' },
{ path: '/library', label: 'Library', icon: Library, color: 'text-accent-cyan' },
{ path: '/add', label: 'Add Podcast', icon: PlusCircle, color: 'text-accent-violet' },
];
return (
<nav className="fixed left-0 top-0 h-screen w-64 bg-white border-r border-light-200 pt-6 overflow-y-auto hidden lg:flex flex-col">
{/* Logo */}
<Link to="/" className="px-6 mb-8 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-primary flex items-center justify-center text-white font-bold">
🎙️
<>
{/* Desktop Sidebar */}
<nav className="fixed left-6 top-6 bottom-6 w-72 h-auto premium-glass z-50 p-6 hidden lg:flex flex-col gap-8 rounded-[2.5rem]">
{/* Logo */}
<Link to="/" className="flex items-center gap-4 group">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-indigo to-accent-rose flex items-center justify-center text-2xl shadow-glow-indigo transition-transform group-hover:scale-110">
🎙️
</div>
<div className="flex flex-col">
<span className="text-xl font-display font-extrabold text-white tracking-tight">PODCASTIC</span>
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-[0.2em]">Premium Audio</span>
</div>
</Link>
{/* Navigation Links */}
<div className="flex-1 space-y-2">
{navItems.map((item) => {
const ActiveIcon = item.icon;
const active = isActive(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`group relative flex items-center gap-4 px-5 py-4 rounded-2xl transition-all duration-300 ${
active
? 'bg-white/10 text-white shadow-lg shadow-black/20'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{active && (
<div className={`absolute left-0 w-1.5 h-6 rounded-full bg-accent-indigo shadow-[0_0_12px_rgba(99,102,241,0.8)]`} />
)}
<ActiveIcon className={`w-5 h-5 transition-transform group-hover:scale-110 ${active ? 'text-accent-indigo' : ''}`} />
<span className="text-sm font-semibold tracking-wide">{item.label}</span>
</Link>
);
})}
</div>
<span className="text-xl font-bold text-light-900">Podcastic</span>
</Link>
{/* Navigation Links */}
<div className="flex-1 px-4">
<div className="space-y-2">
<Link
to="/dashboard"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive('/dashboard')
? 'bg-blue-500 text-white'
: 'text-light-700 hover:bg-light-100'
}`}
>
<Home className="w-5 h-5" />
<span className="font-medium">Tableau de bord</span>
</Link>
<Link
to="/trending"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive('/trending')
? 'bg-orange-500 text-white'
: 'text-light-700 hover:bg-light-100'
}`}
>
<Flame className="w-5 h-5" />
<span className="font-medium">Tendance</span>
</Link>
<Link
to="/library"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive('/library')
? 'bg-blue-500 text-white'
: 'text-light-700 hover:bg-light-100'
}`}
>
<BookOpen className="w-5 h-5" />
<span className="font-medium">Bibliothèque</span>
</Link>
<Link
to="/add"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive('/add')
? 'bg-blue-500 text-white'
: 'text-light-700 hover:bg-light-100'
}`}
>
<PlusCircle className="w-5 h-5" />
<span className="font-medium">Ajouter</span>
</Link>
{/* Footer info/stats */}
<div className="mt-auto pt-6 border-t border-white/5 space-y-4">
<div className="p-4 rounded-3xl bg-white/[0.03] border border-white/[0.05]">
<p className="text-[10px] text-slate-500 font-bold uppercase mb-2">My Stats</p>
<div className="flex justify-between items-end">
<span className="text-2xl font-display font-extrabold text-white">2.4k</span>
<span className="text-[10px] text-accent-indigo font-bold">Mins Listened</span>
</div>
</div>
<p className="px-2 text-[10px] text-slate-600 font-medium">Podcastic Premium © 2026</p>
</div>
</div>
</nav>
{/* Footer */}
<div className="px-6 py-4 border-t border-light-200 text-xs text-light-500">
<p>Podcastic © 2026</p>
</div>
</nav>
{/* Mobile Bottom Nav */}
<nav className="fixed bottom-6 left-6 right-6 h-20 premium-glass lg:hidden z-50 flex items-center justify-around px-4 rounded-[2rem] shadow-2xl">
{navItems.map((item) => {
const MobileIcon = item.icon;
const active = isActive(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`flex flex-col items-center gap-1.5 transition-all duration-300 ${
active ? 'scale-110 text-accent-indigo' : 'text-slate-500'
}`}
>
<MobileIcon className="w-6 h-6" />
{active && <div className="w-1 h-1 rounded-full bg-accent-indigo shadow-[0_0_8px_rgba(99,102,241,1)]" />}
</Link>
);
})}
</nav>
</>
);
}
+32 -30
View File
@@ -1,4 +1,4 @@
import { CheckCircle2, ChevronRight, BookOpen } from 'lucide-react';
import { CheckCircle2, ChevronRight, BookOpen, Sparkles, X } from 'lucide-react';
import { Link } from 'react-router-dom';
interface SuccessModalProps {
@@ -24,61 +24,63 @@ export default function SuccessModal({
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
className="absolute inset-0 bg-obsidian/60 backdrop-blur-md animate-fade-in"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden animate-slide-up">
{/* Top Gradient */}
<div className="h-2 bg-gradient-primary w-full" />
{/* Modal Content */}
<div className="relative premium-glass rounded-[3.5rem] w-full max-w-sm overflow-hidden animate-slide-up shadow-[0_0_50px_rgba(0,0,0,0.5)] border-white/10">
{/* Decorative Aura */}
<div className="absolute -top-24 -right-24 w-48 h-48 bg-accent-indigo/20 blur-[60px] rounded-full" />
<div className="p-8 flex flex-col items-center text-center">
{/* Animated Success Icon */}
<div className="w-16 h-16 bg-green-50 rounded-full flex items-center justify-center mb-6 animate-pulse">
<CheckCircle2 className="w-8 h-8 text-green-500" />
<div className="p-10 flex flex-col items-center text-center relative z-10">
{/* Header Actions */}
<button onClick={onClose} className="absolute top-0 right-0 p-6 text-slate-500 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
{/* Icon */}
<div className="w-20 h-20 bg-accent-cyan/10 rounded-3xl flex items-center justify-center mb-8 shadow-glow-cyan border border-accent-cyan/20">
<Sparkles className="w-8 h-8 text-accent-cyan animate-pulse" />
</div>
<h3 className="text-xl font-bold text-light-900 mb-2">
<h3 className="text-2xl font-display font-black text-white mb-2 leading-tight">
{message}
</h3>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-8">Nouveau membre de votre univers</p>
<div className="w-full bg-light-50 rounded-2xl p-4 mb-8 flex items-center gap-4 border border-light-100">
{podcastImage ? (
<img
src={podcastImage}
alt={podcastTitle}
className="w-16 h-16 rounded-xl object-cover shadow-sm"
/>
) : (
<div className="w-16 h-16 rounded-xl bg-light-200 flex items-center justify-center text-2xl">
🎙️
</div>
)}
<div className="w-full bg-white/[0.03] rounded-[2rem] p-5 mb-10 flex items-center gap-5 border border-white/5">
<div className="w-20 h-20 rounded-2xl overflow-hidden shadow-2xl border border-white/10 shrink-0">
{podcastImage ? (
<img src={podcastImage} alt={podcastTitle} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-accent-indigo to-accent-rose flex items-center justify-center text-2xl">🎙️</div>
)}
</div>
<div className="text-left flex-1 min-w-0">
<p className="font-bold text-light-900 truncate">{podcastTitle}</p>
<p className="text-lg font-bold text-white truncate mb-1">{podcastTitle}</p>
{podcastAuthor && (
<p className="text-sm text-light-500 truncate">{podcastAuthor}</p>
<p className="text-xs font-bold text-accent-indigo truncate uppercase tracking-widest">{podcastAuthor}</p>
)}
</div>
</div>
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col w-full gap-4">
<Link
to="/library"
className="flex items-center justify-center gap-2 w-full py-3.5 rounded-xl bg-blue-500 text-white font-bold hover:bg-blue-600 transition-all shadow-soft active:scale-[0.98]"
className="neon-button !rounded-2xl !py-4 flex items-center justify-center gap-3"
onClick={onClose}
>
<BookOpen className="w-5 h-5" />
Voir dans ma Bibliothèque
<span className="text-xs uppercase tracking-[0.2em]">Ma Bibliothèque</span>
<ChevronRight className="w-4 h-4" />
</Link>
<button
onClick={onClose}
className="w-full py-3.5 rounded-xl bg-light-100 text-light-700 font-bold hover:bg-light-200 transition-all"
className="w-full py-4 rounded-2xl bg-white/5 text-slate-400 text-xs font-black uppercase tracking-widest hover:bg-white/10 hover:text-white transition-all"
>
Continuer la recherche
Continuer
</button>
</div>
</div>
+95 -120
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { discoveryService, DiscoveryPodcast } from '../services/discoveryService';
import { podcastService } from '../services/podcastService';
import Header from '../components/Header';
import { Search, Plus, Loader, Rss, Check } from 'lucide-react';
import { Search, Plus, Loader, Rss, Check, Globe, Sparkles, PlusCircle } from 'lucide-react';
import { authService } from '../services/authService';
import SuccessModal from '../components/SuccessModal';
@@ -69,8 +69,7 @@ export default function AddPodcast() {
setShowSuccessModal(true);
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
} catch (error: any) {
const errorMessage = error.response?.data?.message || 'Failed to subscribe';
alert(`Erreur : ${errorMessage}`);
console.error(error);
} finally {
setSubscribingId(null);
}
@@ -84,7 +83,7 @@ export default function AddPodcast() {
setRssSuccess(null);
try {
const result = await podcastService.subscribe(rssUrl.trim());
setRssSuccess(`"${result.podcast.title}" — ${result.message === 'Déjà abonné' ? 'Déjà dans vos abonnements' : 'ajouté à vos abonnements !'}`);
setRssSuccess(`"${result.podcast.title}" ajouté !`);
setRssUrl('');
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
} catch (error: any) {
@@ -94,145 +93,121 @@ export default function AddPodcast() {
}
};
const handleLogout = async () => {
try {
await authService.logout();
authService.clearTokens();
window.location.href = '/login';
} catch (error) {
console.error('Logout failed:', error);
}
};
return (
<div className="min-h-screen bg-light-50">
<div className="min-h-screen">
<Header
title="Ajouter un Podcast"
subtitle="Trouvez et abonnez-vous à vos podcasts favoris"
title="Ajouter"
subtitle="EXPANSION"
user={user}
onLogout={handleLogout}
onLogout={() => { /* Logout handled in service/navigation usually */ }}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* RSS URL Section */}
<div className="mb-12 card bg-white">
<div className="flex items-center gap-2 mb-3">
<Rss className="w-5 h-5 text-orange-500" />
<h3 className="text-sm font-bold text-light-900 uppercase tracking-wider">Ajouter via URL RSS</h3>
</div>
<form onSubmit={handleRssSubscribe} className="flex gap-3">
<input
type="url"
placeholder="https://feeds.example.com/podcast.rss"
value={rssUrl}
onChange={(e) => { setRssUrl(e.target.value); setRssError(null); setRssSuccess(null); }}
className="flex-1 px-4 py-2.5 rounded-xl border border-light-200 bg-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
/>
<button
type="submit"
disabled={rssLoading || !rssUrl.trim()}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{rssLoading ? <Loader className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
{rssLoading ? 'Ajout...' : 'Ajouter'}
</button>
</form>
{rssError && <p className="mt-2 text-sm text-red-600">{rssError}</p>}
{rssSuccess && <p className="mt-2 text-sm text-green-600">{rssSuccess}</p>}
<main className="max-w-5xl mx-auto">
{/* RSS Input Area */}
<div className="premium-glass p-8 rounded-[3rem] mb-12 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-accent-rose/5 blur-[50px]" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-accent-rose/10 flex items-center justify-center text-accent-rose shadow-glow-indigo">
<Rss className="w-5 h-5" />
</div>
<h3 className="text-xl font-display font-black text-white">Importation Directe</h3>
</div>
<form onSubmit={handleRssSubscribe} className="flex flex-col sm:flex-row gap-4">
<input
type="url"
placeholder="Collez une adresse URL de flux RSS ici..."
value={rssUrl}
onChange={(e) => { setRssUrl(e.target.value); setRssError(null); setRssSuccess(null); }}
className="input-premium flex-1"
/>
<button
type="submit"
disabled={rssLoading || !rssUrl.trim()}
className="neon-button !from-accent-rose !to-accent-rose/80 shrink-0"
>
{rssLoading ? <Loader className="w-4 h-4 animate-spin" /> : <PlusCircle className="w-4 h-4" />}
<span>Ajouter</span>
</button>
</form>
{rssError && <p className="mt-4 text-xs font-bold text-accent-rose animate-pulse">⚠️ {rssError}</p>}
{rssSuccess && <p className="mt-4 text-xs font-bold text-accent-cyan">✨ {rssSuccess}</p>}
</div>
</div>
{/* Search Bar */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
<Search className="w-5 h-5 text-blue-500" />
<h3 className="text-sm font-bold text-light-900 uppercase tracking-wider">Rechercher dans l'annuaire</h3>
</div>
<div className="relative">
<Search className="absolute left-4 top-3.5 w-5 h-5 text-light-400" />
<input
type="text"
placeholder="Rechercher des podcasts, auteurs, sujets..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-3 rounded-xl border border-light-200 bg-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors text-lg"
/>
</div>
{/* Global Directory Search */}
<div className="mb-12">
<div className="flex items-center gap-4 mb-8">
<div className="w-10 h-10 rounded-xl bg-accent-indigo/10 flex items-center justify-center text-accent-indigo shadow-glow-indigo">
<Search className="w-5 h-5" />
</div>
<h3 className="text-xl font-display font-black text-white">Annuaire Global</h3>
</div>
<div className="relative group">
<input
type="text"
placeholder="Rechercher par titre, auteur ou thématique..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input-premium !py-6 !pl-16 text-xl shadow-2xl transition-all group-hover:bg-white/[0.05]"
/>
<Sparkles className="absolute left-6 top-1/2 -translate-y-1/2 w-6 h-6 text-slate-500 group-focus-within:text-accent-indigo transition-colors" />
</div>
</div>
{/* Results Grid */}
{/* Dynamic Results Grid */}
{isSearching ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<Loader className="w-12 h-12 text-blue-500 animate-spin mx-auto mb-3" />
<p className="text-light-600">Recherche en cours...</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[1,2,3].map(i => <div key={i} className="premium-glass rounded-[2.5rem] h-64 animate-pulse bg-white/5" />)}
</div>
) : searchResults?.podcasts && searchResults.podcasts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 pb-32">
{searchResults.podcasts.map((podcast) => (
<div key={podcast.id} className="card hover:shadow-lg transition-shadow bg-white">
{podcast.imageUrl && (
<div className="mb-4 rounded-lg overflow-hidden aspect-video bg-light-200">
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-bold text-light-900 mb-1 line-clamp-2">
{podcast.title}
</h3>
{podcast.author && (
<p className="text-sm text-light-600 mb-2">{podcast.author}</p>
)}
<p className="text-sm text-light-500 line-clamp-3 mb-4">
{podcast.description}
</p>
<div className="flex items-center justify-between">
<button
onClick={() => handleSubscribe(podcast)}
disabled={subscribingId === podcast.id || subscribeMutation.isPending || isSubscribed(podcast.rssUrl)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors font-medium ${
isSubscribed(podcast.rssUrl)
? 'bg-light-200 text-light-500 cursor-default'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed'
}`}
>
{subscribingId === podcast.id ? (
<>
<Loader className="w-4 h-4 animate-spin" />
Ajout...
</>
) : isSubscribed(podcast.rssUrl) ? (
<>
<Check className="w-4 h-4" />
Abonné
</>
) : (
<>
<Plus className="w-4 h-4" />
Ajouter
</>
)}
</button>
</div>
<div key={podcast.id} className="group premium-glass rounded-[2.5rem] overflow-hidden flex flex-col hover:bg-white/[0.05] transition-all duration-500">
<div className="relative aspect-[16/10] overflow-hidden">
{podcast.imageUrl ? (
<img src={podcast.imageUrl} alt={podcast.title} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
) : (
<div className="w-full h-full bg-gradient-to-br from-slate-800 to-obsidian flex items-center justify-center text-4xl">🎙️</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-obsidian via-transparent to-transparent opacity-60" />
</div>
<div className="p-6 flex flex-col flex-1">
<h3 className="text-lg font-bold text-white mb-2 line-clamp-2 leading-tight group-hover:text-accent-indigo transition-colors">{podcast.title}</h3>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-4 truncate">{podcast.author || 'Inconnu'}</p>
<button
onClick={() => handleSubscribe(podcast)}
disabled={subscribingId === podcast.id || subscribeMutation.isPending || isSubscribed(podcast.rssUrl)}
className={`mt-auto w-full py-3.5 rounded-2xl font-bold uppercase tracking-widest text-[10px] transition-all duration-300 flex items-center justify-center gap-2 ${
isSubscribed(podcast.rssUrl)
? 'bg-accent-cyan/10 text-accent-cyan border border-accent-cyan/20'
: 'bg-white text-obsidian hover:bg-accent-indigo hover:text-white'
}`}
>
{subscribingId === podcast.id ? <Loader className="w-4 h-4 animate-spin" /> : isSubscribed(podcast.rssUrl) ? <><Check className="w-4 h-4" /> Membre</> : <><Plus className="w-4 h-4" /> S'abonner</>}
</button>
</div>
</div>
))}
</div>
) : searchQuery.length >= 2 ? (
<div className="text-center py-20">
<p className="text-light-600 text-lg mb-2">Aucun podcast trouvé.</p>
<p className="text-light-500">Essayez de rechercher des titres, auteurs ou sujets</p>
<div className="premium-glass p-20 rounded-[3rem] text-center max-w-lg mx-auto">
<Globe className="w-12 h-12 text-slate-700 mx-auto mb-6" />
<p className="text-white font-bold uppercase tracking-widest text-sm mb-2">Aucun signal trouvé</p>
<p className="text-slate-500 text-xs">Vérifiez l'orthographe ou essayez d'autres mots-clés.</p>
</div>
) : (
<div className="text-center py-20">
<p className="text-light-600 text-lg">Entrez au moins 2 caractères pour lancer la recherche.</p>
<div className="p-8 inline-block rounded-full bg-white/[0.02] border border-white/5 animate-aura">
<Search className="w-12 h-12 text-slate-700" />
</div>
</div>
)}
</main>
<SuccessModal
+61 -39
View File
@@ -5,6 +5,7 @@ import { authService } from '../services/authService';
import Header from '../components/Header';
import EpisodeCard from '../components/EpisodeCard';
import AudioPlayer from '../components/AudioPlayer';
import { Sparkles, Play, Clock, TrendingUp } from 'lucide-react';
export default function Dashboard() {
const [user, setUser] = useState<any>(null);
@@ -47,67 +48,88 @@ export default function Dashboard() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-light">
<div className="text-center">
<div className="w-16 h-16 rounded-full border-4 border-light-200 border-t-blue-500 animate-spin mx-auto mb-4"></div>
<p className="text-light-600">Chargement de vos podcasts...</p>
</div>
<div className="min-h-screen flex items-center justify-center">
<div className="w-12 h-12 rounded-full border-4 border-white/5 border-t-accent-indigo animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-light-50">
<div className="min-h-screen">
<Header
title="Podcastic"
subtitle="Votre compagnon de podcasts"
title={`Bon retour, ${user?.username?.split(' ')[0] || 'Ami'} !`}
subtitle="VOTRE TABLEAU DE BORD"
user={user}
onLogout={handleLogout}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Welcome Section */}
<div className="mb-12">
<h2 className="text-3xl font-bold text-light-900 mb-2">
Bon retour, {user?.username} ! 👋
</h2>
<p className="text-light-600">
{episodesData?.count || 0} nouveaux épisodes de vos abonnements
</p>
<main className="">
{/* Welcome Section / Hero */}
<div className="premium-glass rounded-[3rem] p-8 lg:p-12 mb-12 relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-64 bg-accent-indigo/10 blur-[80px] -mr-32 -mt-32 group-hover:bg-accent-indigo/20 transition-all duration-700" />
<div className="relative z-10">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-accent-indigo/10 border border-accent-indigo/20 text-[10px] font-black text-accent-indigo uppercase tracking-widest mb-4">
<Sparkles className="w-3 h-3" />
Podcastic Premium
</div>
<h2 className="text-4xl lg:text-5xl font-display font-black text-white mb-4 tracking-tight leading-tight max-w-2xl">
Découvrez les nouveautés de votre univers audio.
</h2>
<p className="text-slate-400 text-lg mb-8 max-w-xl leading-relaxed">
Vous avez {episodesData?.count || 0} nouveaux épisodes qui n'attendent que vous. Prêt pour une immersion ?
</p>
<div className="flex flex-wrap gap-4">
<button className="neon-button flex items-center gap-2">
<Play className="w-4 h-4 fill-current" />
Reprendre la lecture
</button>
<button className="px-6 py-3 rounded-2xl bg-white/5 text-slate-300 font-bold text-sm hover:bg-white/10 transition-all border border-white/5">
Parcourir les tendances
</button>
</div>
</div>
</div>
{/* Latest Episodes Section */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-cyan/10 flex items-center justify-center text-accent-cyan">
<Clock className="w-5 h-5" />
</div>
<h3 className="text-2xl font-display font-black text-white">Récemment Publiés</h3>
</div>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-3 py-1 rounded-lg border border-white/5">{episodesData?.episodes?.length || 0} EPISODES</span>
</div>
{/* Latest Episodes Grid */}
{episodesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-12 h-12 rounded-full border-4 border-light-200 border-t-blue-500 animate-spin mx-auto mb-3"></div>
<p className="text-light-600">Chargement des épisodes...</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{[1, 2, 3, 4].map(i => (
<div key={i} className="premium-glass rounded-[2rem] h-80 animate-pulse bg-white/[0.02]" />
))}
</div>
) : episodesData?.episodes && episodesData.episodes.length > 0 ? (
<div>
<h3 className="text-2xl font-bold text-light-900 mb-6">Derniers épisodes</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{episodesData.episodes.map((episode) => (
<EpisodeCard
key={episode._id}
episode={episode}
onPlay={setSelectedEpisode}
/>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{episodesData.episodes.map((episode) => (
<EpisodeCard
key={episode._id}
episode={episode}
onPlay={setSelectedEpisode}
/>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-light-600 text-lg mb-4">Aucun épisode pour l'instant</p>
<p className="text-light-500">
Abonnez-vous à des podcasts pour voir les épisodes dans votre tableau de bord
<div className="premium-glass p-16 rounded-[3rem] text-center max-w-2xl mx-auto border-dashed border-white/10">
<div className="text-4xl mb-6 opacity-40">🧘</div>
<p className="text-white font-bold text-lg mb-2">Silence radio...</p>
<p className="text-slate-500 text-sm leading-relaxed mb-8">
Vous n'avez pas encore d'épisodes ici. Abonnez-vous à vos podcasts favoris pour commencer votre collection.
</p>
<button className="neon-button">Explorer les podcasts</button>
</div>
)}
</main>
{/* Audio Player */}
{/* Audio Player Management */}
{selectedEpisode && (
<AudioPlayer
episode={selectedEpisode}
+126 -197
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { podcastService, Podcast } from '../services/podcastService';
import { authService } from '../services/authService';
import Header from '../components/Header';
import { BookOpen, Trash2, Loader, Rss } from 'lucide-react';
import { BookOpen, Trash2, Loader, Rss, Play, Settings2, RefreshCw, AlertCircle } from 'lucide-react';
import { Link } from 'react-router-dom';
export default function Library() {
@@ -32,51 +32,35 @@ export default function Library() {
const unsubscribeMutation = useMutation({
mutationFn: (podcastId: string) => podcastService.unsubscribe(podcastId),
onMutate: async (podcastId: string) => {
// Cancel outgoing refetches to avoid overwriting our optimistic update
await queryClient.cancelQueries({ queryKey: ['podcasts', 'subscriptions'] });
// Optimistic update: remove the podcast from the local cache immediately
queryClient.setQueryData(
['podcasts', 'subscriptions'],
(old: any) => {
if (!old || !old.podcasts) return old;
// Optimistically filter by subscriptionId or _id as fallback
const filtered = old.podcasts.filter((p: any) =>
String(p.subscriptionId || p._id) !== String(podcastId)
);
return { ...old, podcasts: filtered, count: filtered.length };
}
);
setConfirmDeleteId(null);
},
onSuccess: () => {
// Sync the server state after success
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
onError: (error: any, podcastId) => {
console.error(`[Unsubscribe FAILED] Podcast ID: ${podcastId}`, error);
// Rollback: re-fetch the real data from server if the mutation failed
onError: (error: any) => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
const errorMessage = error.response?.data?.message || 'Erreur inconnue';
alert(`Erreur lors du désabonnement (ID: ${podcastId}) : ${errorMessage}`);
alert('Erreur lors du désabonnement');
},
});
const syncMutation = useMutation({
mutationFn: (podcastId: string) => podcastService.syncPodcast(podcastId),
onSuccess: (data, podcastId) => {
console.log(`[Sync SUCCESS] Podcast ID: ${podcastId}`, data);
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
onError: (error: any, podcastId) => {
console.error(`[Sync FAILED] Podcast ID: ${podcastId}`, error);
const errorMessage = error.response?.data?.message || 'Erreur de synchronisation';
alert(`Erreur : ${errorMessage}`);
},
});
const handleLogout = async () => {
@@ -85,210 +69,155 @@ export default function Library() {
authService.clearTokens();
window.location.href = '/login';
} catch (error) {
console.error('Logout failed:', error);
}
};
const handleUnsubscribeRequest = (id: string) => {
console.log("[UI] Demande de désabonnement reçue pour l'ID (Sub/Pod):", id);
setConfirmDeleteId(id);
};
const handleConfirmUnsubscribe = () => {
if (confirmDeleteId) {
console.log("[UI] Confirmation du désabonnement pour l'ID:", confirmDeleteId);
unsubscribeMutation.mutate(confirmDeleteId);
console.error(error);
}
};
const podcasts: Podcast[] = subscriptionsData?.podcasts ?? [];
return (
<div className="min-h-screen bg-light-50">
<div className="min-h-screen">
<Header
title="Ma Bibliothèque"
subtitle="Gérez vos abonnements aux podcasts"
subtitle="VOTRE COLLECTION"
user={user}
onLogout={handleLogout}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Title row */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-2">
<BookOpen className="w-6 h-6 text-blue-500" />
<h2 className="text-xl font-bold text-light-900">
{isLoading ? 'Chargement...' : `${podcasts.length} abonnement${podcasts.length !== 1 ? 's' : ''}`}
</h2>
{/* Stats Quick View */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div className="premium-glass p-6 rounded-[2rem] flex items-center gap-6">
<div className="w-12 h-12 rounded-2xl bg-accent-indigo/10 flex items-center justify-center text-accent-indigo">
<BookOpen className="w-6 h-6" />
</div>
<div>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Podcasts</p>
<h3 className="text-2xl font-display font-black text-white">{podcasts.length}</h3>
</div>
</div>
<Link
to="/add"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors font-medium text-sm"
>
<Rss className="w-4 h-4" />
Ajouter un podcast
<Link to="/add" className="premium-glass p-6 rounded-[2rem] flex items-center gap-6 group hover:bg-accent-indigo/5 transition-all">
<div className="w-12 h-12 rounded-2xl bg-accent-indigo flex items-center justify-center text-white shadow-glow-indigo group-hover:scale-110 transition-transform">
<Rss className="w-6 h-6" />
</div>
<div>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Action</p>
<h3 className="text-lg font-bold text-white">Ajouter un Podcast</h3>
</div>
</Link>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center py-32">
<div className="w-16 h-16 rounded-full border-4 border-accent-indigo/20 border-t-accent-indigo animate-spin mb-6" />
<p className="text-slate-400 font-medium animate-pulse">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-white/[0.03] flex items-center justify-center text-4xl mx-auto mb-8 border border-white/5">🕳️</div>
<h2 className="text-3xl font-display font-black text-white mb-4">Bibliothèque vide</h2>
<p className="text-slate-400 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">
<Rss className="w-5 h-5" />
Découvrir des podcasts
</Link>
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<Loader className="w-12 h-12 text-blue-500 animate-spin mx-auto mb-3" />
<p className="text-light-600">Chargement de vos abonnements...</p>
</div>
</div>
) : podcasts.length === 0 ? (
<div className="text-center py-20">
<BookOpen className="w-16 h-16 text-light-300 mx-auto mb-4" />
<p className="text-light-600 text-lg mb-2">Votre bibliothèque est vide</p>
<p className="text-light-500 mb-6">Abonnez-vous à des podcasts pour les retrouver ici</p>
<Link
to="/add"
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-blue-500 text-white hover:bg-blue-600 transition-colors font-medium"
>
<Rss className="w-5 h-5" />
Ajouter mon premier podcast
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{podcasts.map((podcast) => (
<div key={podcast._id} className="card bg-white hover:shadow-lg transition-shadow flex flex-col">
{/* Podcast cover */}
{podcast.imageUrl ? (
<div className="mb-4 rounded-lg overflow-hidden aspect-square w-24 h-24 bg-light-200 flex-shrink-0">
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover"
/>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
{podcasts.map((podcast) => (
<div key={podcast._id} className="group premium-glass rounded-[2.5rem] p-6 hover:bg-white/[0.05] transition-all duration-500 border-white/5 hover:border-white/10 flex flex-col h-full">
<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-white/10 group-hover:scale-105 transition-transform duration-500">
{podcast.imageUrl ? (
<img src={podcast.imageUrl} alt={podcast.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-accent-indigo to-accent-violet flex items-center justify-center text-3xl">🎙️</div>
)}
</div>
) : (
<div className="mb-4 w-24 h-24 rounded-lg bg-light-200 flex items-center justify-center flex-shrink-0">
<Rss className="w-8 h-8 text-light-400" />
<div className="absolute -bottom-2 -right-2 w-8 h-8 rounded-xl bg-white text-obsidian flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all">
<Play className="w-4 h-4 fill-current ml-0.5" />
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-base font-bold text-light-900 mb-1 truncate">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-bold text-white truncate leading-tight group-hover:text-accent-indigo transition-colors">
{podcast.title}
</h3>
{podcast.author && (
<p className="text-xs text-light-500 mb-2 truncate">{podcast.author}</p>
)}
{/* Description preview */}
{podcast.description && (
<p className="text-xs text-light-400 line-clamp-2 mb-3 h-8 leading-relaxed">
{podcast.description}
</p>
</div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-3">{podcast.author || 'Artiste Inconnu'}</p>
<div className="flex flex-wrap gap-2">
<span className="px-2.5 py-1 rounded-lg bg-white/5 border border-white/5 text-[9px] font-black text-slate-400 uppercase tracking-tighter">
{podcast.episodeCount} EPISODES
</span>
{podcast.language && (
<span className="px-2.5 py-1 rounded-lg bg-white/5 border border-white/5 text-[9px] font-black text-slate-400 uppercase">
{podcast.language}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 text-[10px] text-light-400 mb-4">
{podcast.episodeCount > 0 && (
<span className="bg-light-100 px-2 py-0.5 rounded-full">
{podcast.episodeCount} épisodes
</span>
)}
{podcast.language && (
<span className="bg-light-100 px-2 py-0.5 rounded-full uppercase">
{podcast.language}
</span>
)}
</div>
{/* Last Episode info */}
{podcast.lastEpisodeTitle && (
<div className="bg-blue-50/50 rounded-lg p-2.5 mb-2 border border-blue-100/50">
<p className="text-[10px] font-semibold text-blue-600 uppercase tracking-wider mb-1">
Dernier épisode
</p>
<p className="text-xs font-medium text-light-800 line-clamp-1 mb-0.5">
{podcast.lastEpisodeTitle}
</p>
{podcast.lastEpisodeDate && (
<p className="text-[10px] text-light-400">
{new Date(podcast.lastEpisodeDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</p>
{/* Status Section */}
<div className="flex-1">
{podcast.episodeCount === 0 ? (
<div className="bg-orange-500/5 border border-orange-500/10 rounded-2xl p-4 mb-4">
<div className="flex items-center gap-3 mb-3">
<AlertCircle className="w-4 h-4 text-orange-500" />
<span className="text-[10px] font-bold text-orange-500 uppercase tracking-widest">Besoin de synchronisation</span>
</div>
<button
onClick={() => syncMutation.mutate(podcast._id)}
disabled={syncMutation.isPending}
className="w-full py-2.5 rounded-xl bg-orange-500 text-white text-[11px] font-bold hover:bg-orange-600 transition-all flex items-center justify-center gap-2"
>
{syncMutation.isPending && syncMutation.variables === podcast._id ? (
<Loader className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
</div>
)}
Synchroniser maintenant
</button>
</div>
{/* Unsubscribe button */}
{confirmDeleteId === (podcast.subscriptionId || podcast._id) ? (
<div className="flex items-center gap-2 mt-auto pt-3 border-t border-light-100">
<span className="text-xs text-light-600 flex-1">Confirmer le désabonnement ?</span>
<button
onClick={handleConfirmUnsubscribe}
disabled={unsubscribeMutation.isPending}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors text-xs font-medium"
>
{unsubscribeMutation.isPending ? (
<Loader className="w-3 h-3 animate-spin" />
) : (
<Trash2 className="w-3 h-3" />
)}
Confirmer
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="px-3 py-1.5 rounded-lg bg-light-200 text-light-700 hover:bg-light-300 transition-colors text-xs font-medium"
>
Annuler
</button>
</div>
) : (
<div className="mt-auto">
{podcast.episodeCount === 0 && (
<div className="mb-4">
<p className="text-[10px] text-orange-500 font-semibold mb-2 flex items-center gap-1 uppercase tracking-wider">
{syncMutation.isPending && syncMutation.variables === (podcast.subscriptionId || podcast._id) ? (
<Loader className="w-3 h-3 animate-spin" />
) : (
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
)}
Aucun épisode trouvé
</p>
<button
onClick={() => syncMutation.mutate(podcast._id)}
disabled={syncMutation.isPending}
className="w-full py-2 rounded-xl bg-orange-50 text-orange-600 hover:bg-orange-100 transition-all text-xs font-bold flex items-center justify-center gap-2 border border-orange-200/50"
>
{syncMutation.isPending && syncMutation.variables === podcast._id ? (
<>
<Loader className="w-3 h-3 animate-spin" />
Synchronisation...
</>
) : (
<>
<Loader className="w-3 h-3" />
Synchroniser maintenant
</>
)}
</button>
</div>
)}
<button
onClick={() => handleUnsubscribeRequest(podcast.subscriptionId || podcast._id)}
className="pt-3 border-t border-light-100 flex items-center gap-2 text-sm text-light-500 hover:text-red-600 transition-colors w-full"
>
<Trash2 className="w-4 h-4" />
Se désabonner
</button>
) : podcast.lastEpisodeTitle && (
<div className="bg-white/[0.02] border border-white/5 rounded-2xl p-4 mb-6 group-hover:bg-white/[0.04] transition-colors">
<p className="text-[10px] font-bold text-accent-indigo uppercase tracking-[0.2em] mb-2">Dernier épisode</p>
<p className="text-sm font-bold text-slate-200 line-clamp-1 mb-1">{podcast.lastEpisodeTitle}</p>
<p className="text-[10px] font-medium text-slate-500 italic">
{new Date(podcast.lastEpisodeDate!).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
)}
</div>
))}
</div>
)}
</main>
{/* Actions Footer */}
<div className="flex items-center justify-between pt-6 border-t border-white/5 mt-auto">
{confirmDeleteId === (podcast.subscriptionId || podcast._id) ? (
<div className="flex items-center gap-3 w-full animate-fade-in">
<button onClick={handleConfirmUnsubscribe} className="flex-1 py-2 bg-accent-rose text-white rounded-xl text-[11px] font-black uppercase hover:bg-accent-rose/80">Confirmer</button>
<button onClick={() => setConfirmDeleteId(null)} className="flex-1 py-2 bg-white/5 text-slate-400 rounded-xl text-[11px] font-black uppercase hover:bg-white/10">Annuler</button>
</div>
) : (
<>
<button className="p-2.5 rounded-xl bg-white/5 text-slate-500 hover:text-white hover:bg-white/10 transition-all">
<Settings2 className="w-4 h-4" />
</button>
<button
onClick={() => setConfirmDeleteId(podcast.subscriptionId || podcast._id)}
className="flex items-center gap-2 text-[10px] font-bold text-slate-600 hover:text-accent-rose transition-all uppercase tracking-widest"
>
<Trash2 className="w-3 h-3" />
Désabonner
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
+77 -80
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService } from '../services/authService';
import { Sparkles, ArrowRight, Loader, Mail, Lock, User as UserIcon } from 'lucide-react';
export default function Login() {
const navigate = useNavigate();
@@ -46,117 +47,113 @@ export default function Login() {
};
return (
<div className="min-h-screen bg-gradient-light flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-primary flex items-center justify-center text-white text-4xl mx-auto mb-4">
<div className="min-h-screen bg-obsidian flex items-center justify-center px-4 relative overflow-hidden font-sans">
{/* Background Auras */}
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_20%_20%,rgba(99,102,241,0.15),transparent_40%),radial-gradient(circle_at_80%_80%,rgba(6,182,212,0.15),transparent_40%)]" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-accent-indigo/5 blur-[120px] rounded-full animate-aura" />
<div className="w-full max-w-lg relative z-10 reveal">
{/* Logo & Header */}
<div className="text-center mb-10">
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-accent-indigo to-accent-rose flex items-center justify-center text-4xl mx-auto mb-6 shadow-glow-indigo transform hover:scale-110 transition-transform duration-500">
🎙️
</div>
<h1 className="text-4xl font-bold text-light-900 mb-2">Podcastic</h1>
<p className="text-light-600">Votre compagnon de streaming de podcasts</p>
<h1 className="text-5xl font-display font-black text-white mb-3 tracking-tighter">PODCASTIC</h1>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-[10px] font-black text-accent-indigo uppercase tracking-[0.2em]">
<Sparkles className="w-3 h-3" />
Expérience Audio Premium
</div>
</div>
{/* Form Card */}
<div className="card mb-6">
<div className="mb-6">
<div className="premium-glass p-8 lg:p-12 rounded-[3rem] shadow-2xl border-white/10">
<div className="flex p-1.5 rounded-2xl bg-white/[0.03] border border-white/5 mb-10">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError(null);
setFormData({ email: '', password: '', username: '' });
}}
className="w-full mb-4"
onClick={() => { setIsLogin(true); setError(null); }}
className={`flex-1 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${
isLogin ? 'bg-white text-obsidian shadow-lg' : 'text-slate-500 hover:text-slate-300'
}`}
>
<div className="flex gap-2">
<button
className={`flex-1 py-2 rounded-lg font-medium transition-colors ${
isLogin
? 'bg-blue-500 text-white'
: 'bg-light-100 text-light-700 hover:bg-light-200'
}`}
onClick={() => setIsLogin(true)}
>
Se connecter
</button>
<button
className={`flex-1 py-2 rounded-lg font-medium transition-colors ${
!isLogin
? 'bg-blue-500 text-white'
: 'bg-light-100 text-light-700 hover:bg-light-200'
}`}
onClick={() => setIsLogin(false)}
>
Créer un compte
</button>
</div>
Connexion
</button>
<button
onClick={() => { setIsLogin(false); setError(null); }}
className={`flex-1 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${
!isLogin ? 'bg-white text-obsidian shadow-lg' : 'text-slate-500 hover:text-slate-300'
}`}
>
Inscription
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-6">
{!isLogin && (
<input
type="text"
name="username"
placeholder="Nom d'utilisateur"
value={formData.username}
onChange={handleChange}
required={!isLogin}
className="input"
/>
<div className="relative group">
<UserIcon className="absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-accent-indigo transition-colors" />
<input
type="text"
name="username"
placeholder="NOM D'UTILISATEUR"
value={formData.username}
onChange={handleChange}
required={!isLogin}
className="input-premium !pl-14 text-xs font-bold uppercase tracking-widest"
/>
</div>
)}
<input
type="email"
name="email"
placeholder="Adresse e-mail"
value={formData.email}
onChange={handleChange}
required
className="input"
/>
<div className="relative group">
<Mail className="absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-accent-indigo transition-colors" />
<input
type="email"
name="email"
placeholder="ADRESSE E-MAIL"
value={formData.email}
onChange={handleChange}
required
className="input-premium !pl-14 text-xs font-bold uppercase tracking-widest"
/>
</div>
<input
type="password"
name="password"
placeholder="Mot de passe"
value={formData.password}
onChange={handleChange}
required
className="input"
/>
<div className="relative group">
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-accent-indigo transition-colors" />
<input
type="password"
name="password"
placeholder="MOT DE PASSE"
value={formData.password}
onChange={handleChange}
required
className="input-premium !pl-14 text-xs font-bold uppercase tracking-widest"
/>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 text-red-700 text-sm">
{error}
<div className="p-4 rounded-2xl bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-[10px] font-black uppercase tracking-widest text-center animate-pulse">
⚠️ {error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full"
className="neon-button w-full h-14 flex items-center justify-center gap-3 text-xs uppercase tracking-[0.2em]"
>
{loading ? (
<div className="flex items-center justify-center gap-2">
<div className="spinner border-white"></div>
{isLogin ? 'Connexion en cours...' : 'Création du compte...'}
</div>
) : isLogin ? (
'Se connecter'
<div className="w-5 h-5 rounded-full border-2 border-white/20 border-t-white animate-spin" />
) : (
'Créer un compte'
<>
{isLogin ? 'Commencer l\'écoute' : 'Créer mon univers'}
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
</div>
{/* Footer */}
<p className="text-center text-sm text-light-600">
{isLogin
? "Pas de compte ? Cliquez sur Créer un compte ci-dessus"
: 'Déjà un compte ? Cliquez sur Se connecter ci-dessus'}
<p className="mt-8 text-center text-[10px] font-bold text-slate-600 uppercase tracking-[0.3em]">
Podcastic High-Fidelity Audio © 2026
</p>
</div>
</div>
+67 -61
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { discoveryService, DiscoveryPodcast } from '../services/discoveryService';
import { podcastService } from '../services/podcastService';
import Header from '../components/Header';
import { Loader, Plus, Flame, Check } from 'lucide-react';
import { Loader, Plus, Flame, Check, Sparkles, TrendingUp } from 'lucide-react';
import { authService } from '../services/authService';
import SuccessModal from '../components/SuccessModal';
@@ -63,8 +63,7 @@ export default function Trending() {
setShowSuccessModal(true);
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
} catch (error: any) {
const errorMessage = error.response?.data?.message || 'Failed to subscribe';
alert(`Erreur : ${errorMessage}`);
console.error(error);
} finally {
setSubscribingId(null);
}
@@ -81,86 +80,93 @@ export default function Trending() {
};
return (
<div className="min-h-screen bg-light-50">
<div className="min-h-screen">
<Header
title="Tendances"
subtitle="Découvrez les podcasts les plus populaires"
subtitle="EXPLORATION"
user={user}
onLogout={handleLogout}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-center gap-2 mb-8">
<Flame className="w-6 h-6 text-orange-500" />
<h2 className="text-xl font-bold text-light-900">Podcasts Tendances</h2>
<main className="">
<div className="flex items-center gap-3 mb-10">
<div className="w-10 h-10 rounded-xl bg-accent-rose/10 flex items-center justify-center text-accent-rose">
<Flame className="w-6 h-6 animate-pulse" />
</div>
<div>
<h2 className="text-2xl font-display font-black text-white">Top Podcasts</h2>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Mis à jour en temps réel</p>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<Loader className="w-12 h-12 text-blue-500 animate-spin mx-auto mb-3" />
<p className="text-light-600">Chargement des podcasts...</p>
</div>
<div className="flex flex-col items-center justify-center py-32">
<div className="w-12 h-12 rounded-full border-4 border-white/5 border-t-accent-rose animate-spin mb-4" />
<p className="text-slate-500 font-bold uppercase tracking-widest text-[10px]">Analyse des tendances...</p>
</div>
) : trendingData?.podcasts && trendingData.podcasts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{trendingData.podcasts.map((podcast) => (
<div key={podcast.id} className="card hover:shadow-lg transition-shadow">
{podcast.imageUrl && (
<div className="mb-4 rounded-lg overflow-hidden aspect-video bg-light-200">
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-bold text-light-900 mb-1 line-clamp-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{trendingData.podcasts.map((podcast, index) => (
<div key={podcast.id} className="group premium-glass rounded-[2.5rem] overflow-hidden flex flex-col hover:bg-white/[0.05] transition-all duration-500">
<div className="relative aspect-[16/10] overflow-hidden">
{podcast.imageUrl ? (
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-slate-800 to-obsidian flex items-center justify-center text-4xl">🎙️</div>
)}
<div className="absolute top-4 left-4 px-3 py-1 rounded-full bg-black/40 backdrop-blur-md border border-white/10 text-[10px] font-black text-white uppercase tracking-widest">
Rank #{index + 1}
</div>
<div className="absolute inset-0 bg-gradient-to-t from-obsidian via-transparent to-transparent opacity-60" />
</div>
<div className="p-6 flex-1 flex flex-col">
<h3 className="text-lg font-bold text-white mb-2 line-clamp-2 leading-tight group-hover:text-accent-rose transition-colors">
{podcast.title}
</h3>
{podcast.author && (
<p className="text-sm text-light-600 mb-2">{podcast.author}</p>
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4 truncate">{podcast.author}</p>
)}
<p className="text-sm text-light-500 line-clamp-3 mb-4">
<p className="text-xs text-slate-400 line-clamp-3 mb-6 leading-relaxed flex-1">
{podcast.description}
</p>
<div className="flex items-center justify-between">
<button
onClick={() => handleSubscribe(podcast)}
disabled={subscribingId === podcast.id || subscribeMutation.isPending || isSubscribed(podcast.rssUrl)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors font-medium ${
isSubscribed(podcast.rssUrl)
? 'bg-light-200 text-light-500 cursor-default'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed'
}`}
>
{subscribingId === podcast.id ? (
<>
<Loader className="w-4 h-4 animate-spin" />
Ajout...
</>
) : isSubscribed(podcast.rssUrl) ? (
<>
<Check className="w-4 h-4" />
Abonné
</>
) : (
<>
<Plus className="w-4 h-4" />
Ajouter
</>
)}
</button>
</div>
<button
onClick={() => handleSubscribe(podcast)}
disabled={subscribingId === podcast.id || subscribeMutation.isPending || isSubscribed(podcast.rssUrl)}
className={`w-full py-3.5 rounded-2xl font-bold uppercase tracking-widest text-[10px] transition-all duration-300 flex items-center justify-center gap-2 ${
isSubscribed(podcast.rssUrl)
? 'bg-accent-cyan/10 text-accent-cyan border border-accent-cyan/20 cursor-default'
: 'bg-white text-obsidian hover:bg-accent-rose hover:text-white shadow-lg active:scale-95'
}`}
>
{subscribingId === podcast.id ? (
<Loader className="w-4 h-4 animate-spin" />
) : isSubscribed(podcast.rssUrl) ? (
<>
<Check className="w-4 h-4" />
À l'écoute
</>
) : (
<>
<Plus className="w-4 h-4" />
S'abonner
</>
)}
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-light-600 text-lg mb-2">Aucun podcast tendance trouvé.</p>
</div>
<div className="premium-glass p-20 rounded-[3rem] text-center">
<Sparkles className="w-12 h-12 text-slate-700 mx-auto mb-6" />
<p className="text-white font-bold uppercase tracking-widest">Aucune tendance pour le moment</p>
</div>
)}
</main>
+53 -61
View File
@@ -2,109 +2,101 @@
@tailwind components;
@tailwind utilities;
:root {
--obsidian: #020617;
--accent-indigo: #6366f1;
--accent-cyan: #06b6d4;
}
* {
@apply box-border;
}
html {
scroll-behavior: smooth;
@apply scroll-smooth;
}
body {
@apply bg-light-50 text-light-900 antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
@apply bg-obsidian text-slate-200 antialiased font-sans transition-colors duration-500 overflow-x-hidden;
background-image:
radial-gradient(circle at 0% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
radial-gradient(circle at 100% 100%, rgba(6, 182, 212, 0.08) 0%, transparent 50%);
min-height: 100vh;
}
/* Custom scrollbar */
/* Scrollbar Style */
::-webkit-scrollbar {
@apply w-2 h-2;
@apply w-1.5 h-1.5;
}
::-webkit-scrollbar-track {
@apply bg-light-100;
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-light-300 rounded-full hover:bg-light-400;
@apply bg-white/10 rounded-full hover:bg-white/20 transition-colors;
}
/* Apple TV Style Glassmorphism */
.glass {
@apply backdrop-blur-xl bg-white/30 border border-white/20 rounded-2xl;
/* Premium Typography */
h1, h2, h3, h4 {
@apply font-display font-extrabold tracking-tight text-white;
}
.glass-dark {
@apply backdrop-blur-xl bg-black/30 border border-white/10 rounded-2xl;
/* Premium Glassmorphism */
.premium-glass {
@apply bg-white/[0.03] backdrop-blur-2xl border border-white/[0.08] shadow-glass;
}
/* Glossy effect with shadows */
.glossy {
@apply shadow-soft;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.premium-glass-hover {
@apply transition-all duration-300 hover:bg-white/[0.06] hover:border-white/[0.15] hover:shadow-glow-indigo;
}
.glossy:hover {
@apply shadow-lg;
transform: translateY(-2px);
}
/* Smooth gradient backgrounds */
.gradient-light {
background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
}
.gradient-primary {
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
.glass-nav {
@apply bg-obsidian/40 backdrop-blur-xl border-white/[0.05];
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
@keyframes aura-fade {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
.animate-aura {
animation: aura-fade 10s ease-in-out infinite;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
@keyframes content-reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
.reveal {
animation: content-reveal 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
/* Custom components */
.btn-primary {
@apply px-6 py-3 rounded-lg bg-blue-500 text-white font-medium hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
/* Custom UI Components */
.neon-button {
@apply relative overflow-hidden px-6 py-3 rounded-2xl font-bold transition-all duration-300
bg-gradient-to-r from-accent-indigo to-accent-violet text-white shadow-glow-indigo
hover:scale-[1.02] hover:shadow-indigo-500/40 active:scale-[0.98];
}
.btn-secondary {
@apply px-6 py-3 rounded-lg bg-light-200 text-light-900 font-medium hover:bg-light-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
.input-premium {
@apply w-full px-5 py-4 rounded-2xl bg-white/[0.03] border border-white/[0.08] text-white
focus:outline-none focus:ring-2 focus:ring-accent-indigo/30 focus:border-accent-indigo/50
placeholder:text-slate-500 transition-all;
}
.card {
@apply bg-white rounded-2xl p-6 shadow-soft;
.tab-active {
@apply relative text-white after:absolute after:bottom-0 after:left-1/2 after:-translate-x-1/2
after:w-1 after:h-1 after:bg-accent-indigo after:rounded-full after:shadow-[0_0_8px_rgba(99,102,241,1)];
}
.input {
@apply w-full px-4 py-3 rounded-lg border border-light-200 bg-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors;
/* Utils */
.text-balance {
text-wrap: balance;
}
/* Loading spinner */
.spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent;
.mask-fade-bottom {
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
}
+32 -19
View File
@@ -7,34 +7,47 @@ export default {
theme: {
extend: {
colors: {
obsidian: {
DEFAULT: '#020617',
900: '#020617',
800: '#0f172a',
700: '#1e293b',
},
accent: {
indigo: '#6366f1',
cyan: '#06b6d4',
rose: '#f43f5e',
violet: '#8b5cf6',
},
light: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
}
},
backdropFilter: {
'none': 'none',
'blur': 'blur(10px)',
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
sans: ['"Outfit"', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
display: ['"Montserrat"', 'sans-serif'],
},
borderRadius: {
xl: '1rem',
'2xl': '1.5rem',
'2xl': '1.25rem',
'3xl': '2rem',
'4xl': '3rem',
},
boxShadow: {
'soft': '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03)',
'glow': '0 0 30px rgba(59, 130, 246, 0.1)',
'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
'glow-indigo': '0 0 20px rgba(99, 102, 241, 0.3)',
'glow-cyan': '0 0 20px rgba(6, 182, 212, 0.3)',
},
backdropBlur: {
'xs': '2px',
}
},
},