mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
Refonte thème + fixes lecteur + suppression faux stats
AudioPlayer: - Supprime section vitesse de lecture - -30s/+30s (compact et expanded), avec icônes RotateCcw/RotateCw - Rewind compact passé de -15s à -30s Thème: - globals.css refait : animations slide-up/fade-in/reveal définies, glass card améliorée - Background plus profond (#080d1a), glow/blur plus soignés - Boutons neon-button avec hover translateY Corrections: - Supprime Settings2 (bouton sans action) dans Library - Supprime stat "2.4k mins listened" inventée dans Navigation - Stats Library passent de 3 à 2 colonnes (réelles uniquement) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3b356ce26
commit
b73f06c2b2
4 files changed
+267
-303
No files matched your search
@@ -5,17 +5,12 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
Volume2,
|
Volume2,
|
||||||
SkipBack,
|
VolumeX,
|
||||||
SkipForward,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
|
||||||
List,
|
|
||||||
VolumeX,
|
|
||||||
FastForward,
|
|
||||||
Rewind,
|
|
||||||
X,
|
X,
|
||||||
Info
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface AudioPlayerProps {
|
interface AudioPlayerProps {
|
||||||
@@ -30,8 +25,6 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -40,256 +33,230 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
|
|||||||
// Save progress periodically
|
// Save progress periodically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!episode || !userId) return;
|
if (!episode || !userId) return;
|
||||||
|
const interval = setInterval(async () => {
|
||||||
const saveProgressInterval = setInterval(async () => {
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
await episodeService.saveProgress(episode._id, Math.floor(currentTime), false);
|
||||||
await episodeService.saveProgress(
|
} catch {}
|
||||||
episode._id,
|
|
||||||
Math.floor(currentTime),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save progress:', error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 30000); // Save every 30 seconds
|
}, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
return () => clearInterval(saveProgressInterval);
|
|
||||||
}, [episode, userId, currentTime, isPlaying]);
|
}, [episode, userId, currentTime, isPlaying]);
|
||||||
|
|
||||||
// Load previous progress
|
// Load previous progress
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!episode || !userId) return;
|
if (!episode || !userId) return;
|
||||||
|
episodeService.getProgress(episode._id).then((r) => {
|
||||||
const loadProgress = async () => {
|
if (r.progress?.position && audioRef.current) {
|
||||||
try {
|
audioRef.current.currentTime = r.progress.position;
|
||||||
const response = await episodeService.getProgress(episode._id);
|
|
||||||
if (response.progress) {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = response.progress.position;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load progress:', error);
|
|
||||||
}
|
}
|
||||||
};
|
}).catch(() => {});
|
||||||
|
|
||||||
loadProgress();
|
|
||||||
}, [episode, userId]);
|
}, [episode, userId]);
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) audioRef.current.pause();
|
||||||
if (isPlaying) {
|
else audioRef.current.play();
|
||||||
audioRef.current.pause();
|
|
||||||
} else {
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
const handleSeek = (delta: number) => {
|
||||||
if (audioRef.current) {
|
if (!audioRef.current) return;
|
||||||
setCurrentTime(audioRef.current.currentTime);
|
audioRef.current.currentTime = Math.max(0, Math.min(duration, currentTime + delta));
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setDuration(audioRef.current.duration);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newTime = parseFloat(e.target.value);
|
const t = parseFloat(e.target.value);
|
||||||
setCurrentTime(newTime);
|
setCurrentTime(t);
|
||||||
if (audioRef.current) {
|
if (audioRef.current) audioRef.current.currentTime = t;
|
||||||
audioRef.current.currentTime = newTime;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newVolume = parseFloat(e.target.value);
|
const v = parseFloat(e.target.value);
|
||||||
setVolume(newVolume);
|
setVolume(v);
|
||||||
if (audioRef.current) {
|
setIsMuted(v === 0);
|
||||||
audioRef.current.volume = newVolume;
|
if (audioRef.current) audioRef.current.volume = v;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSpeedChange = (speed: number) => {
|
const toggleMute = () => {
|
||||||
setPlaybackSpeed(speed);
|
if (!audioRef.current) return;
|
||||||
if (audioRef.current) {
|
const muted = !isMuted;
|
||||||
audioRef.current.playbackRate = speed;
|
setIsMuted(muted);
|
||||||
}
|
audioRef.current.volume = muted ? 0 : volume;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (s: number) => {
|
||||||
if (!isFinite(seconds)) return '0:00';
|
if (!isFinite(s)) return '0:00';
|
||||||
const mins = Math.floor(seconds / 60);
|
const m = Math.floor(s / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const sec = Math.floor(s % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!episode) return null;
|
if (!episode) return null;
|
||||||
|
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`fixed z-[100] transition-all duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
||||||
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
|
||||||
isExpanded ? 'h-[80vh] bottom-1/2 translate-y-1/2 rounded-[3rem]' : 'h-24 rounded-[2.5rem]'
|
? 'inset-0 rounded-none'
|
||||||
} premium-glass p-0 shadow-2xl border-white/10 flex flex-col overflow-hidden animate-slide-up`}
|
: 'bottom-6 left-1/2 -translate-x-1/2 w-[96%] max-w-5xl rounded-[2rem] h-[88px]'
|
||||||
>
|
} premium-glass border-white/10 shadow-2xl overflow-hidden flex flex-col`}>
|
||||||
|
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src={episode.audioUrl}
|
src={episode.audioUrl}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)}
|
||||||
onEnded={() => setIsPlaying(false)}
|
onEnded={() => setIsPlaying(false)}
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
autoPlay
|
autoPlay
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* COMPACT VIEW (DOCK) */}
|
{/* ── COMPACT BAR ── */}
|
||||||
<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'}`}>
|
<div className={`flex items-center gap-4 px-5 h-[88px] shrink-0 ${isExpanded ? 'hidden' : 'flex'}`}>
|
||||||
{/* Info */}
|
|
||||||
<div className="flex items-center gap-4 min-w-[200px] lg:min-w-[300px] max-w-[400px]">
|
{/* Artwork + info */}
|
||||||
<div className="w-16 h-16 rounded-2xl overflow-hidden shadow-lg border border-white/10 shrink-0">
|
<div className="flex items-center gap-3 min-w-0 flex-1 max-w-[280px]">
|
||||||
{podcast?.imageUrl ? (
|
<div className="w-13 h-13 w-12 h-12 rounded-xl overflow-hidden border border-white/10 shrink-0 shadow-lg">
|
||||||
<img src={podcast.imageUrl} alt={podcast.title} className="w-full h-full object-cover" />
|
{podcast?.imageUrl
|
||||||
) : (
|
? <img src={podcast.imageUrl} alt="" 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-2xl">🎙️</div>
|
: <div className="w-full h-full bg-gradient-to-br from-accent-indigo to-accent-violet flex items-center justify-center text-xl">🎙️</div>
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden">
|
<div className="min-w-0">
|
||||||
<h4 className="text-sm font-bold text-white truncate leading-tight mb-1">{episode.title}</h4>
|
<p className="text-sm font-bold text-white truncate leading-tight">{episode.title}</p>
|
||||||
<p className="text-[10px] text-slate-400 font-bold truncate uppercase tracking-widest">{podcast?.title || 'Unknown Podcast'}</p>
|
<p className="text-[10px] text-slate-500 font-bold truncate uppercase tracking-wider">{podcast?.title || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Playback Controls */}
|
{/* Controls + progress */}
|
||||||
<div className="flex-1 flex flex-col items-center gap-2">
|
<div className="flex-1 flex flex-col items-center gap-1.5">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-5">
|
||||||
<button
|
<button onClick={() => handleSeek(-30)} className="group flex flex-col items-center gap-0.5 text-slate-500 hover:text-white transition-colors">
|
||||||
onClick={() => audioRef.current && (audioRef.current.currentTime -= 15)}
|
<RotateCcw className="w-4 h-4" />
|
||||||
className="text-slate-400 hover:text-white transition-all hover:scale-110 active:scale-90"
|
<span className="text-[8px] font-black">30</span>
|
||||||
>
|
|
||||||
<Rewind className="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handlePlayPause}
|
||||||
onClick={handlePlayPause}
|
className="w-11 h-11 rounded-full bg-white text-obsidian flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
|
||||||
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-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current ml-0.5" />}
|
||||||
>
|
|
||||||
{isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-0.5" />}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => handleSeek(30)} className="group flex flex-col items-center gap-0.5 text-slate-500 hover:text-white transition-colors">
|
||||||
onClick={() => audioRef.current && (audioRef.current.currentTime += 30)}
|
<RotateCw className="w-4 h-4" />
|
||||||
className="text-slate-400 hover:text-white transition-all hover:scale-110 active:scale-90"
|
<span className="text-[8px] font-black">30</span>
|
||||||
>
|
|
||||||
<FastForward className="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full max-w-lg flex items-center gap-2">
|
||||||
<div className="w-full max-w-xl flex items-center gap-3 group">
|
<span className="text-[9px] text-slate-600 tabular-nums w-8 text-right">{formatTime(currentTime)}</span>
|
||||||
<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 group cursor-pointer">
|
||||||
<div className="relative flex-1 h-1.5 flex items-center cursor-pointer">
|
<input type="range" min="0" max={duration || 0} value={currentTime}
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={duration || 0}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={handleProgressChange}
|
onChange={handleProgressChange}
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
|
||||||
/>
|
<div className="w-full h-full bg-white/10 rounded-full overflow-hidden">
|
||||||
<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 rounded-full transition-all duration-100"
|
||||||
<div
|
style={{ width: `${progress}%` }} />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-bold text-slate-500 w-10 tabular-nums">{formatTime(duration)}</span>
|
<span className="text-[9px] text-slate-600 tabular-nums w-8">{formatTime(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar-like utility */}
|
{/* Right actions */}
|
||||||
<div className="flex items-center gap-4 min-w-[200px] justify-end">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button onClick={toggleMute} className="p-2 text-slate-500 hover:text-white transition-colors">
|
||||||
onClick={() => setIsExpanded(true)}
|
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
||||||
className="p-3 rounded-2xl bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/10 transition-all"
|
</button>
|
||||||
>
|
<button onClick={() => setIsExpanded(true)}
|
||||||
<Maximize2 className="w-4 h-4" />
|
className="p-2 rounded-xl bg-white/5 text-slate-500 hover:text-white hover:bg-white/10 transition-all">
|
||||||
</button>
|
<Maximize2 className="w-4 h-4" />
|
||||||
<button
|
</button>
|
||||||
onClick={onClose}
|
<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"
|
className="p-2 rounded-xl bg-white/5 text-slate-500 hover:text-accent-rose hover:bg-accent-rose/10 transition-all">
|
||||||
>
|
<X className="w-4 h-4" />
|
||||||
<X className="w-4 h-4" />
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* EXPANDED VIEW */}
|
{/* ── EXPANDED VIEW ── */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="flex-1 flex flex-col p-12 transition-all duration-500 reveal">
|
<div className="flex-1 flex flex-col p-8 lg:p-16 overflow-y-auto">
|
||||||
<div className="flex justify-between items-start mb-12">
|
{/* Top bar */}
|
||||||
<button onClick={() => setIsExpanded(false)} className="p-4 rounded-3xl bg-white/5 text-slate-400 hover:text-white transition-all">
|
<div className="flex items-center justify-between mb-10">
|
||||||
<ChevronDown className="w-6 h-6" />
|
<button onClick={() => setIsExpanded(false)}
|
||||||
</button>
|
className="p-3 rounded-2xl bg-white/5 text-slate-400 hover:text-white transition-all">
|
||||||
<div className="text-center">
|
<ChevronDown className="w-5 h-5" />
|
||||||
<span className="text-[11px] font-bold text-accent-indigo uppercase tracking-[0.3em] mb-2 block">Premium Experience</span>
|
</button>
|
||||||
<h2 className="text-xl font-display font-extrabold text-white">Now Playing</h2>
|
<p className="text-[10px] font-black text-accent-indigo uppercase tracking-[0.3em]">En cours de lecture</p>
|
||||||
</div>
|
<button onClick={onClose}
|
||||||
<button onClick={onClose} className="p-4 rounded-3xl bg-white/5 text-slate-400 hover:text-accent-rose transition-all">
|
className="p-3 rounded-2xl bg-white/5 text-slate-400 hover:text-accent-rose transition-all">
|
||||||
<X className="w-6 h-6" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center flex-1">
|
<div className="flex flex-col lg:flex-row gap-12 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">
|
{/* Artwork */}
|
||||||
{podcast?.imageUrl ? (
|
<div className="w-64 h-64 lg:w-80 lg:h-80 rounded-[2.5rem] overflow-hidden shadow-2xl border border-white/10 shrink-0">
|
||||||
<img src={podcast.imageUrl} alt={podcast.title} className="w-full h-full object-cover scale-105" />
|
{podcast?.imageUrl
|
||||||
) : (
|
? <img src={podcast.imageUrl} alt="" className="w-full h-full object-cover" />
|
||||||
<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 className="w-full h-full bg-gradient-to-br from-accent-indigo to-accent-violet flex items-center justify-center text-7xl">🎙️</div>
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-8">
|
{/* Info + controls */}
|
||||||
<div>
|
<div className="flex flex-col gap-8 flex-1 w-full max-w-lg">
|
||||||
<h1 className="text-5xl font-display font-black text-white leading-tight mb-4">{episode.title}</h1>
|
<div>
|
||||||
<p className="text-lg text-slate-400 font-medium leading-relaxed line-clamp-4">{episode.description}</p>
|
<p className="text-xs font-black text-accent-indigo uppercase tracking-widest mb-2">{podcast?.title}</p>
|
||||||
</div>
|
<h2 className="text-3xl lg:text-4xl font-display font-black text-white leading-tight mb-3">{episode.title}</h2>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed line-clamp-3">{episode.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Progress */}
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div>
|
||||||
<span className="text-xs font-bold text-accent-indigo tabular-nums">{formatTime(currentTime)}</span>
|
<div className="relative h-1.5 group cursor-pointer mb-2">
|
||||||
<span className="text-xs font-bold text-slate-500 tabular-nums">{formatTime(duration)}</span>
|
<input type="range" min="0" max={duration || 0} value={currentTime}
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={duration || 0}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={handleProgressChange}
|
onChange={handleProgressChange}
|
||||||
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer accent-accent-indigo"
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
|
||||||
/>
|
<div className="w-full h-full bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-gradient-to-r from-accent-indigo to-accent-cyan rounded-full"
|
||||||
|
style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-slate-500 tabular-nums">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-12">
|
{/* Controls */}
|
||||||
<button onClick={() => handleSpeedChange(playbackSpeed === 2 ? 1 : playbackSpeed + 0.5)} className="text-xs font-bold text-slate-400 hover:text-white">
|
<div className="flex items-center justify-center gap-8">
|
||||||
SPEED {playbackSpeed}x
|
<button onClick={() => handleSeek(-30)}
|
||||||
</button>
|
className="flex flex-col items-center gap-1 text-slate-400 hover:text-white transition-colors group">
|
||||||
<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">
|
<RotateCcw className="w-6 h-6 group-hover:scale-110 transition-transform" />
|
||||||
{isPlaying ? <Pause className="w-10 h-10 fill-current" /> : <Play className="w-10 h-10 fill-current ml-2" />}
|
<span className="text-[9px] font-black">-30s</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Volume2 className="w-5 h-5 text-slate-400" />
|
<button onClick={handlePlayPause}
|
||||||
<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" />
|
className="w-20 h-20 rounded-full bg-white text-obsidian flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
|
||||||
</div>
|
{isPlaying ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
|
<button onClick={() => handleSeek(30)}
|
||||||
|
className="flex flex-col items-center gap-1 text-slate-400 hover:text-white transition-colors group">
|
||||||
|
<RotateCw className="w-6 h-6 group-hover:scale-110 transition-transform" />
|
||||||
|
<span className="text-[9px] font-black">+30s</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={toggleMute} className="text-slate-400 hover:text-white transition-colors shrink-0">
|
||||||
|
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<input type="range" min="0" max="1" step="0.05" value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="flex-1 h-1 bg-white/10 rounded-full appearance-none cursor-pointer accent-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function Navigation() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-auto pt-6 border-t border-white/5">
|
<div className="mt-auto pt-6 border-t border-white/5">
|
||||||
<p className="px-2 text-[10px] text-slate-600 font-medium">Podcastic © 2026</p>
|
<p className="px-2 text-[10px] text-slate-700 font-bold uppercase tracking-widest">Podcastic © 2026</p>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { podcastService, Podcast } from '../services/podcastService';
|
import { podcastService, Podcast } from '../services/podcastService';
|
||||||
import { authService } from '../services/authService';
|
import { authService } from '../services/authService';
|
||||||
import Header from '../components/Header';
|
import Header from '../components/Header';
|
||||||
import { BookOpen, Trash2, Loader, Rss, Play, Settings2, RefreshCw, AlertCircle } from 'lucide-react';
|
import { BookOpen, Trash2, Loader, Rss, Play, RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export default function Library() {
|
export default function Library() {
|
||||||
@@ -90,26 +90,26 @@ export default function Library() {
|
|||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Quick View */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
<div className="grid grid-cols-2 gap-4 mb-10">
|
||||||
<div className="premium-glass p-6 rounded-[2rem] flex items-center gap-6">
|
<div className="premium-glass p-5 rounded-[1.5rem] flex items-center gap-4">
|
||||||
<div className="w-12 h-12 rounded-2xl bg-accent-indigo/10 flex items-center justify-center text-accent-indigo">
|
<div className="w-10 h-10 rounded-xl bg-accent-indigo/10 flex items-center justify-center text-accent-indigo shrink-0">
|
||||||
<BookOpen className="w-6 h-6" />
|
<BookOpen className="w-5 h-5" />
|
||||||
</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>
|
</div>
|
||||||
<Link to="/add" className="premium-glass p-6 rounded-[2rem] flex items-center gap-6 group hover:bg-accent-indigo/5 transition-all">
|
<div>
|
||||||
<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">
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Abonnements</p>
|
||||||
<Rss className="w-6 h-6" />
|
<h3 className="text-2xl font-display font-black text-white">{podcasts.length}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Action</p>
|
<Link to="/add" className="premium-glass p-5 rounded-[1.5rem] flex items-center gap-4 group hover:bg-accent-indigo/5 transition-all">
|
||||||
<h3 className="text-lg font-bold text-white">Ajouter un Podcast</h3>
|
<div className="w-10 h-10 rounded-xl bg-accent-indigo flex items-center justify-center text-white shadow-glow-indigo group-hover:scale-110 transition-transform shrink-0">
|
||||||
</div>
|
<Rss className="w-5 h-5" />
|
||||||
</Link>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Action</p>
|
||||||
|
<h3 className="text-base font-bold text-white">Ajouter un podcast</h3>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -206,18 +206,13 @@ export default function Library() {
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<button
|
||||||
<button className="p-2.5 rounded-xl bg-white/5 text-slate-500 hover:text-white hover:bg-white/10 transition-all">
|
onClick={() => setConfirmDeleteId(podcast.subscriptionId || podcast._id)}
|
||||||
<Settings2 className="w-4 h-4" />
|
className="ml-auto flex items-center gap-2 text-[10px] font-bold text-slate-600 hover:text-accent-rose transition-all uppercase tracking-widest"
|
||||||
</button>
|
>
|
||||||
<button
|
<Trash2 className="w-3 h-3" />
|
||||||
onClick={() => setConfirmDeleteId(podcast.subscriptionId || podcast._id)}
|
Désabonner
|
||||||
className="flex items-center gap-2 text-[10px] font-bold text-slate-600 hover:text-accent-rose transition-all uppercase tracking-widest"
|
</button>
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
Désabonner
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,100 +3,102 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--obsidian: #020617;
|
--obsidian: #080d1a;
|
||||||
--accent-indigo: #6366f1;
|
--accent-indigo: #6366f1;
|
||||||
--accent-cyan: #06b6d4;
|
--accent-cyan: #06b6d4;
|
||||||
|
--accent-rose: #f43f5e;
|
||||||
|
--accent-violet: #8b5cf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* { @apply box-border; }
|
||||||
@apply box-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
html { @apply scroll-smooth; }
|
||||||
@apply scroll-smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-obsidian text-slate-200 antialiased font-sans transition-colors duration-500 overflow-x-hidden;
|
background-color: var(--obsidian);
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle at 0% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
radial-gradient(ellipse 80% 40% at 10% 10%, rgba(99,102,241,0.07) 0%, transparent 60%),
|
||||||
radial-gradient(circle at 100% 100%, rgba(6, 182, 212, 0.08) 0%, transparent 50%);
|
radial-gradient(ellipse 60% 50% at 90% 90%, rgba(6,182,212,0.06) 0%, transparent 60%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
@apply text-slate-200 antialiased font-sans overflow-x-hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar Style */
|
/* ── Scrollbar ── */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
@apply w-1.5 h-1.5;
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
}
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 99px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
/* ── Typography ── */
|
||||||
@apply bg-transparent;
|
h1,h2,h3,h4 { @apply font-display font-extrabold tracking-tight text-white; }
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
/* ── Glass card ── */
|
||||||
@apply bg-white/10 rounded-full hover:bg-white/20 transition-colors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium Typography */
|
|
||||||
h1, h2, h3, h4 {
|
|
||||||
@apply font-display font-extrabold tracking-tight text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium Glassmorphism */
|
|
||||||
.premium-glass {
|
.premium-glass {
|
||||||
@apply bg-white/[0.03] backdrop-blur-2xl border border-white/[0.08] shadow-glass;
|
background: rgba(255,255,255,0.025);
|
||||||
}
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
.premium-glass-hover {
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
@apply transition-all duration-300 hover:bg-white/[0.06] hover:border-white/[0.15] hover:shadow-glow-indigo;
|
box-shadow: 0 8px 40px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||||
}
|
|
||||||
|
|
||||||
.glass-nav {
|
|
||||||
@apply bg-obsidian/40 backdrop-blur-xl border-white/[0.05];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes aura-fade {
|
|
||||||
0%, 100% { opacity: 0.5; transform: scale(1); }
|
|
||||||
50% { opacity: 0.8; transform: scale(1.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-aura {
|
|
||||||
animation: aura-fade 10s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes content-reveal {
|
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal {
|
|
||||||
animation: content-reveal 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inputs ── */
|
||||||
.input-premium {
|
.input-premium {
|
||||||
@apply w-full px-5 py-4 rounded-2xl bg-white/[0.03] border border-white/[0.08] text-white
|
@apply w-full px-5 py-4 rounded-2xl text-white
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent-indigo/30 focus:border-accent-indigo/50
|
placeholder:text-slate-600 transition-all outline-none;
|
||||||
placeholder:text-slate-500 transition-all;
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
}
|
||||||
|
.input-premium:focus {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-color: rgba(99,102,241,0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99,102,241,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-active {
|
/* ── Buttons ── */
|
||||||
@apply relative text-white after:absolute after:bottom-0 after:left-1/2 after:-translate-x-1/2
|
.neon-button {
|
||||||
after:w-1 after:h-1 after:bg-accent-indigo after:rounded-full after:shadow-[0_0_8px_rgba(99,102,241,1)];
|
@apply relative inline-flex items-center justify-center gap-2 px-6 py-3 rounded-2xl
|
||||||
|
font-bold text-white transition-all duration-300 active:scale-[0.97] select-none;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
box-shadow: 0 4px 20px rgba(99,102,241,0.35), inset 0 1px 0 rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
.neon-button:hover {
|
||||||
|
box-shadow: 0 6px 28px rgba(99,102,241,0.5), inset 0 1px 0 rgba(255,255,255,0.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.neon-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: none;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utils */
|
/* ── Animations ── */
|
||||||
.text-balance {
|
@keyframes fade-in {
|
||||||
text-wrap: balance;
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.mask-fade-bottom {
|
@keyframes slide-up {
|
||||||
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes reveal {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aura-pulse {
|
||||||
|
0%,100% { opacity: 0.5; transform: scale(1); }
|
||||||
|
50% { opacity: 0.8; transform: scale(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal { animation: reveal 0.5s cubic-bezier(0.2,0.8,0.2,1) both; }
|
||||||
|
.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.2,0.8,0.2,1) both; }
|
||||||
|
.animate-fade-in { animation: fade-in 0.3s ease both; }
|
||||||
|
.animate-aura { animation: aura-pulse 10s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ── Utility ── */
|
||||||
|
.text-balance { text-wrap: balance; }
|
||||||
|
.shadow-glass { box-shadow: 0 8px 40px rgba(0,0,0,0.4); }
|
||||||
|
.shadow-glow-indigo { box-shadow: 0 0 20px rgba(99,102,241,0.35); }
|
||||||
|
.shadow-glow-cyan { box-shadow: 0 0 20px rgba(6,182,212,0.35); }
|
||||||
Reference in new issue
Block a user