mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
feat: initialize frontend entry point with HTML boilerplate and custom fonts
This commit is contained in:
1 parent
f441251bb1
commit
6e9da01488
14 files changed
+872
-907
No files matched your search
+5
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user