feat: redesign complet de l'application basé sur les maquettes Stitch

Refonte du design system (CSS variables, composants glass/card/button/badge),
nouvelle page PodcastDetails avec route /podcast/:id,
lecteur audio immersif avec visualiseur audio,
animations, thème dark/light, responsive mobile/desktop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.7 committed 2026-04-28 13:22:25 +02:00
1 parent ca23996c3b
commit 5c2a6438cd
20 files changed
+1456 -1563

No files matched your search

+7 -1
View File
@@ -6,7 +6,13 @@
"Bash(git remote:*)",
"Bash(git branch:*)",
"Bash(git push:*)",
"Bash(ls -la /c/Claude/Podcastic/Dockerfile*)"
"Bash(ls -la /c/Claude/Podcastic/Dockerfile*)",
"Bash(npm run *)",
"WebFetch(domain:stitch.withgoogle.com)",
"Bash(npx tsc *)",
"Bash(git add *)",
"Bash(git pull *)",
"Bash(git stash *)"
]
}
}
+5 -5
View File
@@ -1,17 +1,17 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Podcastic - Stream your favorite podcasts" />
<meta name="description" content="Podcastic - Votre univers audio premium" />
<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>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700;800&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Podcastic - Streaming Audio Premium</title>
</head>
<body>
<body class="dark-theme">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+40 -59
View File
@@ -1,31 +1,31 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import './styles/globals.css';
import { authService } from './services/authService';
import { AudioProvider, useAudio } from './context/AudioContext';
import { ThemeProvider } from './context/ThemeContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Trending from './pages/Trending';
import AddPodcast from './pages/AddPodcast';
import Library from './pages/Library';
import PodcastDetails from './pages/PodcastDetails';
import Navigation from './components/Navigation';
import AudioPlayer from './components/AudioPlayer';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
retry: 1,
},
},
});
// App Content Wrapper to access AudioContext
function AppContent() {
const { currentEpisode, closePlayer } = useAudio();
const location = useLocation();
const isDashboard = location.pathname === '/dashboard' || location.pathname === '/';
const [user, setUser] = useState<any>(null);
useEffect(() => {
@@ -34,58 +34,37 @@ function AppContent() {
authService.getMe().then(r => setUser(r.user)).catch(() => {});
}, [currentEpisode]);
return (
<>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/trending"
element={
<ProtectedRoute>
<Trending />
</ProtectedRoute>
}
/>
<Route
path="/add"
element={
<ProtectedRoute>
<AddPodcast />
</ProtectedRoute>
}
/>
<Route
path="/library"
element={
<ProtectedRoute>
<Library />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
const isDashboard = window.location.pathname === '/dashboard' || window.location.pathname === '/';
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={
<ProtectedRoute><Dashboard /></ProtectedRoute>
} />
<Route path="/trending" element={
<ProtectedRoute><Trending /></ProtectedRoute>
} />
<Route path="/add" element={
<ProtectedRoute><AddPodcast /></ProtectedRoute>
} />
<Route path="/library" element={
<ProtectedRoute><Library /></ProtectedRoute>
} />
<Route path="/podcast/:id" element={
<ProtectedRoute><PodcastDetails /></ProtectedRoute>
} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
{/* Audio player — shown when not on dashboard (dashboard has inline player) */}
{currentEpisode && !isDashboard && (
<AudioPlayer
episode={currentEpisode}
onClose={closePlayer}
userId={user?._id}
/>
<AudioPlayer episode={currentEpisode} onClose={closePlayer} userId={user?._id} />
)}
</>
</Routes>
);
}
// Protected Route Component
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { accessToken } = authService.getTokens();
@@ -96,8 +75,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<Navigation />
<main className="flex-1 min-w-0 w-full lg:ml-80 pb-40 pt-8 px-4 sm:px-6 lg:px-8 overflow-x-hidden">
<div className="reveal max-w-[1400px] mx-auto">
<main className="flex-1 min-w-0 w-full lg:ml-72 pb-40 pt-8 px-4 sm:px-6 lg:px-10 overflow-x-hidden">
<div className="animate-fade-in max-w-[1400px] mx-auto">
{children}
</div>
</main>
@@ -108,11 +87,13 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AudioProvider>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</AudioProvider>
<ThemeProvider>
<AudioProvider>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</AudioProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
+195 -344
View File
@@ -3,19 +3,11 @@ import { createPortal } from 'react-dom';
import { Episode } from '../services/episodeService';
import { useAudio } from '../context/AudioContext';
import {
Play,
Pause,
Volume2,
VolumeX,
ChevronDown,
Maximize2,
X,
RotateCcw,
RotateCw,
Play, Pause, Volume2, VolumeX, ChevronDown,
Maximize2, X, RotateCcw, RotateCw, Headphones,
} from 'lucide-react';
import AlertModal from './AlertModal';
import AudioVisualizer from './AudioVisualizer';
import { useTheme } from '../context/ThemeContext';
interface AudioPlayerProps {
episode: Episode | null;
@@ -26,50 +18,31 @@ interface AudioPlayerProps {
export default function AudioPlayer({ episode, onClose, userId, mode = 'floating' }: AudioPlayerProps) {
const {
isPlaying,
currentTime,
duration,
volume,
playbackSpeed,
isMuted,
isResuming,
error,
togglePlay,
seek,
seekRelative,
setVolume,
toggleMute,
changeSpeed,
setError,
setUserId,
isPlaying, currentTime, duration, volume, playbackSpeed,
isMuted, isResuming, error,
togglePlay, seek, seekRelative, setVolume, toggleMute, changeSpeed,
setError, setUserId,
} = useAudio();
const { theme } = useTheme();
const [isExpanded, setIsExpanded] = useState(false);
const [showMiniBar, setShowMiniBar] = useState(false);
const inlineWrapperRef = useRef<HTMLDivElement>(null);
const inlineRef = useRef<HTMLDivElement>(null);
// Sync userId into context so context can save/load progress
useEffect(() => {
setUserId(userId ?? null);
}, [userId, setUserId]);
useEffect(() => { setUserId(userId ?? null); }, [userId, setUserId]);
// IntersectionObserver: show mini-bar when inline player scrolls out of view
useEffect(() => {
if (mode !== 'inline' || !inlineWrapperRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
setShowMiniBar(!entry.isIntersecting);
},
if (mode !== 'inline' || !inlineRef.current) return;
const obs = new IntersectionObserver(
([entry]) => setShowMiniBar(!entry.isIntersecting),
{ threshold: 0, rootMargin: '-40px 0px 0px 0px' }
);
observer.observe(inlineWrapperRef.current);
return () => observer.disconnect();
obs.observe(inlineRef.current);
return () => obs.disconnect();
}, [mode, episode?._id]);
const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null;
const formatTime = (s: number) => {
const fmt = (s: number) => {
if (!isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
@@ -79,388 +52,266 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
if (!episode) return null;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const inverseTheme = theme === 'light' ? 'dark-theme' : 'light-theme';
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
seek(parseFloat(e.target.value));
};
const handleProgress = (e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value));
const handleVolume = (e: React.ChangeEvent<HTMLInputElement>) => setVolume(parseFloat(e.target.value));
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value));
};
const closeAlert = () => { setError(null); onClose(); };
// ── INLINE MODE (embedded in hero card) ──
if (mode === 'inline') {
return (
<>
<div ref={inlineWrapperRef} className="premium-glass rounded-[var(--radius-panel)] p-8 lg:p-10 relative overflow-hidden border border-[var(--border-color)]">
<AlertModal
isOpen={!!error}
title="Erreur de lecture"
message={error || ""}
type="error"
onClose={() => {
setError(null);
onClose();
}}
/>
{/* Glow backdrop */}
<div className={`absolute top-0 right-0 w-64 h-64 bg-[var(--accent-glow)] blur-[80px] -mr-32 -mt-32 pointer-events-none transition-opacity duration-700 ${isPlaying ? 'animate-aura opacity-100' : 'opacity-40'}`} />
<div className="relative z-10 flex flex-col lg:flex-row gap-8 items-start lg:items-center">
{/* Artwork */}
<div className="relative shrink-0">
{/* Halo aura derrière l'artwork */}
{isPlaying && (
<div
className="absolute inset-0 rounded-[var(--radius-card)] bg-[var(--accent-primary)]/20 blur-xl animate-aura scale-110 pointer-events-none -z-10"
aria-hidden="true"
/>
)}
<div className="w-36 h-36 lg:w-44 lg:h-44 rounded-[var(--radius-card)] overflow-hidden shadow-2xl border border-[var(--border-color)]">
{(episode.imageUrl || podcast?.imageUrl)
? <img
src={episode.imageUrl || podcast!.imageUrl}
alt=""
className="w-full h-full object-cover"
/>
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-5xl">🎙️</div>
}
</div>
{isPlaying && (
<div className="absolute -bottom-2 -right-2 px-3 py-1 rounded-full bg-[var(--accent-primary)] text-white text-[9px] font-black uppercase tracking-widest shadow-glow-indigo animate-pulse">
En cours
</div>
)}
const MiniPlayer = ({ fullWidth }: { fullWidth?: boolean }) => (
<div className={`fixed z-[90] mx-auto animate-slide-up max-w-5xl ${fullWidth
? 'bottom-6 left-4 right-4 sm:left-6 sm:right-6 lg:left-auto lg:right-8 lg:bottom-8 lg:w-[calc(100%-22rem)] xl:w-[calc(100%-24rem)]'
: 'bottom-6 left-4 right-4 sm:left-6 sm:right-6'}`}>
<div className="glass rounded-[var(--radius-lg)] shadow-2xl overflow-hidden">
<div className="flex items-center gap-3 px-4 h-[68px]">
<div className="w-10 h-10 rounded-lg overflow-hidden border border-[var(--border-color)] shrink-0 shadow-md">
{(episode.imageUrl || podcast?.imageUrl)
? <img src={episode.imageUrl || podcast!.imageUrl} alt="" className="w-full h-full object-cover" />
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-base">🎙️</div>}
</div>
{/* Info + controls */}
<div className="flex-1 w-full min-w-0">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--accent-glow)] border border-[var(--border-color)] text-[10px] font-black text-[var(--accent-primary)] uppercase tracking-widest mb-3">
Lecture en cours
</div>
<p className="text-[10px] font-black text-[var(--text-secondary)] uppercase tracking-widest mb-1 truncate">
{podcast?.title || ''}
</p>
<h2 className="text-2xl lg:text-3xl font-display font-black leading-tight mb-2 line-clamp-2">
{episode.title}
</h2>
{isResuming && (
<p className="text-[10px] text-[var(--accent-primary)] font-black uppercase animate-pulse">Reprise de lecture...</p>
)}
</div>
<button
onClick={onClose}
className="p-2 rounded-xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-accent-rose hover:bg-accent-rose/10 transition-all shrink-0"
aria-label="Fermer le lecteur"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="min-w-0 flex-1 hidden sm:block">
<p className="text-xs font-bold truncate leading-tight">{episode.title}</p>
{isResuming
? <p className="text-[0.6rem] text-[var(--accent-primary)] font-bold uppercase animate-pulse">Reprise...</p>
: <p className="text-[0.6rem] text-[var(--text-muted)] font-semibold truncate uppercase tracking-wider">{podcast?.title || ''}</p>}
</div>
{/* Progress */}
<div className="mb-5">
<div className="relative h-1.5 group cursor-pointer mb-2">
<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-full bg-[var(--text-primary)]/10 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] rounded-full transition-all duration-100"
style={{ width: `${progress}%` }} />
</div>
</div>
<div className="flex justify-between text-[10px] text-[var(--text-secondary)] tabular-nums font-bold">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => seekRelative(-30)} className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="-30s">
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={togglePlay}
className="w-10 h-10 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-lg shadow-[var(--accent-primary)]/20 hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current ml-0.5" />}
</button>
<button onClick={() => seekRelative(30)} className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="+30s">
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Visualiseur audio */}
<div className="w-full flex justify-center my-5">
<AudioVisualizer barCount={28} height={80} gap={3} />
</div>
<div className="hidden md:flex items-center gap-1.5 shrink-0 text-[0.65rem] text-[var(--text-secondary)] tabular-nums font-semibold">
<span>{fmt(currentTime)}</span><span className="opacity-30">/</span><span>{fmt(duration)}</span>
</div>
{/* Controls */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-5">
<button onClick={() => seekRelative(-30)}
className="flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
<RotateCcw className="w-5 h-5" />
<span className="text-[8px] font-black">-30s</span>
</button>
<button onClick={togglePlay}
className="w-14 h-14 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-0.5" />}
</button>
<button onClick={() => seekRelative(30)}
className="flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
<RotateCw className="w-5 h-5" />
<span className="text-[8px] font-black">+30s</span>
</button>
</div>
<div className="flex items-center gap-2">
<button onClick={changeSpeed}
className="px-3 py-2 rounded-xl bg-[var(--bg-secondary)] text-[10px] font-black text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-widest">
{playbackSpeed}x
</button>
<button onClick={toggleMute}
className="p-2 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
<input type="range" min="0" max="1" step="0.05" value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-[var(--text-primary)]/10 rounded-full appearance-none cursor-pointer accent-[var(--accent-primary)]" />
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button onClick={changeSpeed} className="hidden sm:block px-2 py-1 rounded-md text-[0.6rem] font-bold text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--bg-surface)] transition-all uppercase">
{playbackSpeed}x
</button>
<button onClick={() => setIsExpanded(true)}
className="p-1.5 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--bg-surface)] transition-all"
aria-label="Agrandir">
<Maximize2 className="w-4 h-4" />
</button>
<button onClick={onClose}
className="p-1.5 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent-rose)] hover:bg-[var(--bg-surface)] transition-all"
aria-label="Fermer">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="relative h-[3px] group cursor-pointer">
<input type="range" min="0" max={duration || 0} value={currentTime}
onChange={handleProgress}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
<div className="w-full h-full bg-[var(--border-color)]">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] transition-all duration-150"
style={{ width: `${progress}%` }} />
</div>
</div>
</div>
</div>
);
{/* Mini sticky bar appears when inline player scrolls out of view */}
{showMiniBar && createPortal(
<div className="fixed bottom-6 left-4 right-4 sm:left-6 sm:right-6 lg:left-auto lg:right-8 lg:bottom-8 lg:w-[calc(100%-22rem)] xl:w-[calc(100%-24rem)] z-[90] max-w-5xl mx-auto animate-slide-up">
<div className="premium-glass rounded-[var(--radius-card)] border border-[var(--border-color)] shadow-2xl overflow-hidden">
<div className="flex items-center gap-3 px-4 h-[72px]">
{/* Artwork */}
<div className="w-11 h-11 rounded-xl overflow-hidden border border-[var(--border-color)] shrink-0">
// ── INLINE MODE ──
if (mode === 'inline') {
return (
<>
<div ref={inlineRef} className="glass rounded-[var(--radius-xl)] p-8 lg:p-10 relative overflow-hidden">
<AlertModal isOpen={!!error} title="Erreur de lecture" message={error || ""} type="error" onClose={closeAlert} />
<div className={`absolute top-0 right-0 w-64 h-64 bg-[var(--accent-primary)]/8 blur-[80px] -mr-32 -mt-32 pointer-events-none transition-opacity duration-700 ${isPlaying ? 'animate-aura opacity-100' : 'opacity-40'}`} />
<div className="relative z-10 flex flex-col lg:flex-row gap-8 items-start lg:items-center">
<div className="relative shrink-0">
{isPlaying && (
<div className="absolute inset-0 rounded-2xl bg-[var(--accent-primary)]/20 blur-xl animate-aura scale-110 pointer-events-none -z-10" aria-hidden="true" />
)}
<div className="w-36 h-36 lg:w-44 lg:h-44 rounded-2xl overflow-hidden shadow-2xl border border-[var(--border-color)] player-artwork-ring">
{(episode.imageUrl || podcast?.imageUrl)
? <img src={episode.imageUrl || podcast!.imageUrl} alt="" className="w-full h-full object-cover" />
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-lg">🎙️</div>
}
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-5xl">🎙️</div>}
</div>
{/* Title */}
<div className="min-w-0 flex-1 hidden sm:block">
<p className="text-xs font-bold truncate leading-tight">{episode.title}</p>
<p className="text-[9px] text-[var(--text-secondary)] font-bold truncate uppercase tracking-wider">{podcast?.title || ''}</p>
</div>
{/* Controls */}
<div className="flex items-center gap-3 shrink-0">
<button onClick={() => seekRelative(-30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="-30s">
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={togglePlay}
className="w-10 h-10 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current ml-0.5" />}
</button>
<button onClick={() => seekRelative(30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="+30s">
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Time + scroll up */}
<div className="hidden md:flex items-center gap-3 shrink-0 text-[10px] text-[var(--text-secondary)] tabular-nums font-bold">
<span>{formatTime(currentTime)}</span>
<span className="opacity-40">/</span>
<span>{formatTime(duration)}</span>
</div>
<button onClick={() => inlineWrapperRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
className="p-2 rounded-xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all shrink-0"
aria-label="Remonter au lecteur"
title="Remonter au lecteur">
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
{isPlaying && (
<div className="absolute -bottom-2 -right-2 px-3 py-1 rounded-full bg-[var(--accent-primary)] text-white text-[0.6rem] font-extrabold uppercase tracking-wider shadow-lg shadow-[var(--accent-primary)]/30 animate-pulse">
En cours
</div>
)}
</div>
{/* Progress bar */}
<div className="relative h-1 group 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-full bg-[var(--text-primary)]/10">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] transition-all duration-100"
style={{ width: `${progress}%` }} />
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
}
// ── FLOATING MODE (all pages except dashboard) ──
return (
<>
<AlertModal
isOpen={!!error}
title="Erreur de lecture"
message={error || ""}
type="error"
onClose={() => {
setError(null);
onClose();
}}
/>
{/* ── COMPACT UNIFIED MINI-BAR ── */}
{!isExpanded && (
<div className="fixed bottom-6 left-4 right-4 sm:left-6 sm:right-6 lg:left-auto lg:right-8 lg:bottom-8 lg:w-[calc(100%-22rem)] xl:w-[calc(100%-24rem)] z-[90] max-w-5xl mx-auto animate-slide-up">
<div className="premium-glass rounded-[var(--radius-card)] border border-[var(--border-color)] shadow-2xl overflow-hidden">
<div className="flex items-center gap-3 px-4 h-[72px]">
{/* Artwork */}
<div className="w-11 h-11 rounded-xl overflow-hidden border border-[var(--border-color)] shrink-0">
{(episode.imageUrl || podcast?.imageUrl)
? <img src={episode.imageUrl || podcast!.imageUrl} alt="" className="w-full h-full object-cover" />
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-lg">🎙️</div>
}
</div>
{/* Title */}
<div className="min-w-0 flex-1 hidden sm:block">
<p className="text-xs font-bold truncate leading-tight">{episode.title}</p>
{isResuming ? (
<p className="text-[9px] text-[var(--accent-primary)] font-black uppercase animate-pulse">Reprise de lecture...</p>
) : (
<p className="text-[9px] text-[var(--text-secondary)] font-bold truncate uppercase tracking-wider">{podcast?.title || ''}</p>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-3 shrink-0">
<button onClick={() => seekRelative(-30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="-30s">
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={togglePlay}
className="w-10 h-10 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current ml-0.5" />}
</button>
<button onClick={() => seekRelative(30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="+30s">
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Time */}
<div className="hidden md:flex items-center gap-2 shrink-0 text-[10px] text-[var(--text-secondary)] tabular-nums font-bold">
<span>{formatTime(currentTime)}</span>
<span className="opacity-40">/</span>
<span>{formatTime(duration)}</span>
</div>
{/* Speed + Volume + Expand + Close */}
<div className="flex items-center gap-1 shrink-0">
<button onClick={changeSpeed} className="hidden sm:block px-2 py-1 rounded-lg bg-[var(--bg-secondary)] text-[10px] font-black text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-tighter w-10">
{playbackSpeed}x
</button>
<button onClick={toggleMute} className="hidden sm:block p-2 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
<button onClick={() => setIsExpanded(true)}
className="p-2 rounded-xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all"
aria-label="Agrandir">
<Maximize2 className="w-4 h-4" />
</button>
<div className="flex-1 w-full min-w-0">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="min-w-0">
<span className="badge badge-accent mb-3">
<Headphones className="w-3 h-3" /> Lecture en cours
</span>
<p className="text-[0.6rem] font-bold text-[var(--text-muted)] uppercase tracking-widest mb-1 truncate">
{podcast?.title || ''}
</p>
<h2 className="text-2xl lg:text-3xl mb-2 line-clamp-2">{episode.title}</h2>
{isResuming && (
<p className="text-[0.6rem] text-[var(--accent-primary)] font-bold uppercase animate-pulse">Reprise de lecture...</p>
)}
</div>
<button onClick={onClose}
className="p-2 rounded-xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-accent-rose transition-all"
className="p-2 rounded-lg bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:text-[var(--accent-rose)] hover:bg-[var(--accent-rose)]/10 transition-all shrink-0"
aria-label="Fermer">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Progress bar */}
<div className="relative h-1 group 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-full bg-[var(--text-primary)]/10">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] transition-all duration-100"
style={{ width: `${progress}%` }} />
{/* Audio Visualizer */}
<div className="w-full flex justify-center my-3">
<AudioVisualizer barCount={28} height={70} gap={3} />
</div>
{/* Progress */}
<div className="mb-4">
<div className="relative h-1.5 group cursor-pointer mb-2">
<input type="range" min="0" max={duration || 0} value={currentTime}
onChange={handleProgress}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
<div className="w-full h-full bg-[var(--border-color)] rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] rounded-full transition-all"
style={{ width: `${progress}%` }} />
</div>
</div>
<div className="flex justify-between text-[0.65rem] text-[var(--text-muted)] tabular-nums font-semibold">
<span>{fmt(currentTime)}</span><span>{fmt(duration)}</span>
</div>
</div>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-5">
<button onClick={() => seekRelative(-30)}
className="flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
<RotateCcw className="w-5 h-5" /><span className="text-[0.55rem] font-extrabold">-30s</span>
</button>
<button onClick={togglePlay}
className="w-14 h-14 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-lg shadow-[var(--accent-primary)]/20 hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-0.5" />}
</button>
<button onClick={() => seekRelative(30)}
className="flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
<RotateCw className="w-5 h-5" /><span className="text-[0.55rem] font-extrabold">+30s</span>
</button>
</div>
<div className="flex items-center gap-2">
<button onClick={changeSpeed}
className="px-3 py-2 rounded-lg bg-[var(--bg-surface)] text-[0.65rem] font-bold text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-wider">
{playbackSpeed}x
</button>
<button onClick={toggleMute} className="p-2 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
<input type="range" min="0" max="1" step="0.05" value={isMuted ? 0 : volume}
onChange={handleVolume} className="w-20" />
</div>
</div>
</div>
</div>
</div>
)}
{/* ── EXPANDED VIEW ── */}
{isExpanded && (
<div className={`fixed inset-0 z-[100] ${inverseTheme} text-[var(--text-primary)] ${theme === 'light' ? 'bg-gradient-to-r from-slate-800/95 to-slate-900/95 backdrop-blur-xl' : 'premium-glass'} overflow-hidden flex flex-col`}>
{showMiniBar && createPortal(<MiniPlayer fullWidth />, document.body)}
</>
);
}
// ── FLOATING MODE ──
return (
<>
<AlertModal isOpen={!!error} title="Erreur de lecture" message={error || ""} type="error" onClose={closeAlert} />
{!isExpanded ? (
<MiniPlayer />
) : (
<div className="fixed inset-0 z-[100] bg-[var(--bg-base)]/95 backdrop-blur-2xl overflow-hidden flex flex-col">
<div className="flex-1 flex flex-col p-8 lg:p-16 overflow-y-auto">
{/* Top bar */}
<div className="flex items-center justify-between mb-10">
<button onClick={() => setIsExpanded(false)}
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all">
className="w-11 h-11 rounded-xl bg-[var(--bg-surface)] border border-[var(--border-color)] flex items-center justify-center text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:border-[var(--border-hover)] transition-all">
<ChevronDown className="w-5 h-5" />
</button>
<p className="text-[10px] font-black text-[var(--accent-primary)] uppercase tracking-[0.3em]">En cours de lecture</p>
<span className="text-[0.65rem] font-bold text-[var(--accent-primary)] uppercase tracking-[0.2em]">En cours de lecture</span>
<button onClick={onClose}
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-accent-rose transition-all">
className="w-11 h-11 rounded-xl bg-[var(--bg-surface)] border border-[var(--border-color)] flex items-center justify-center text-[var(--text-secondary)] hover:text-[var(--accent-rose)] hover:border-[var(--border-hover)] transition-all">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col lg:flex-row gap-12 items-center flex-1">
{/* Artwork */}
<div className="w-64 h-64 lg:w-80 lg:h-80 rounded-[2.5rem] overflow-hidden shadow-2xl border border-[var(--border-color)] shrink-0">
<div className="w-64 h-64 lg:w-80 lg:h-80 rounded-[2rem] overflow-hidden shadow-2xl border border-[var(--border-color)] shrink-0 player-artwork-ring">
{(episode.imageUrl || podcast?.imageUrl)
? <img src={episode.imageUrl || podcast!.imageUrl} alt="" className="w-full h-full object-cover" />
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-7xl">🎙️</div>
}
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-7xl">🎙️</div>}
</div>
{/* Info + controls */}
<div className="flex flex-col gap-8 flex-1 w-full max-w-lg">
<div>
<p className="text-xs font-black text-[var(--accent-primary)] uppercase tracking-widest mb-2">{podcast?.title}</p>
<h2 className="text-3xl lg:text-4xl font-display font-black leading-tight mb-3">{episode.title}</h2>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-3">{episode.description}</p>
<p className="text-xs font-bold text-[var(--accent-primary)] uppercase tracking-widest mb-2">
{podcast?.title}
</p>
<h2 className="text-3xl lg:text-4xl mb-3">{episode.title}</h2>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-3">
{episode.description}
</p>
</div>
{/* Progress */}
<AudioVisualizer barCount={36} height={80} gap={3} />
<div>
<div className="relative h-1.5 group cursor-pointer mb-2">
<input type="range" min="0" max={duration || 0} value={currentTime}
onChange={handleProgressChange}
onChange={handleProgress}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
<div className="w-full h-full bg-[var(--text-primary)]/10 rounded-full overflow-hidden">
<div className="w-full h-full bg-[var(--border-color)] rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] rounded-full"
style={{ width: `${progress}%` }} />
</div>
</div>
<div className="flex justify-between text-xs text-[var(--text-secondary)] tabular-nums">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
<div className="flex justify-between text-xs text-[var(--text-muted)] tabular-nums">
<span>{fmt(currentTime)}</span><span>{fmt(duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-8">
<button onClick={() => seekRelative(-30)}
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCcw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">-30s</span>
<span className="text-[0.6rem] font-extrabold">-30s</span>
</button>
<button onClick={togglePlay}
className="w-20 h-20 rounded-full bg-[var(--text-primary)] text-[var(--bg-primary)] flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
className="w-20 h-20 rounded-full bg-[var(--text-primary)] text-[var(--bg-base)] flex items-center justify-center shadow-2xl hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
</button>
<button onClick={() => seekRelative(30)}
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">+30s</span>
<span className="text-[0.6rem] font-extrabold">+30s</span>
</button>
</div>
{/* Volume */}
<div className="flex items-center gap-4">
<button onClick={changeSpeed} className="px-3 py-1.5 rounded-xl bg-[var(--bg-secondary)] text-xs font-black text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-widest">
<button onClick={changeSpeed}
className="px-3 py-1.5 rounded-lg bg-[var(--bg-surface)] text-xs font-bold text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-wider border border-[var(--border-color)]">
{playbackSpeed}x Speed
</button>
<button onClick={toggleMute} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] 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-[var(--text-primary)]/10 rounded-full appearance-none cursor-pointer accent-[var(--accent-primary)]" />
onChange={handleVolume} className="flex-1" />
</div>
</div>
</div>
+71 -102
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { Episode } from '../services/episodeService';
import { Play, Pause, Calendar, Activity, CheckCircle2, Circle } from 'lucide-react';
import { Episode } from '../services/episodeService';
import { useAudio } from '../context/AudioContext';
interface EpisodeCardProps {
@@ -14,144 +13,114 @@ export default function EpisodeCard({ episode, onPlay, onDetails, onToggleRead }
const { currentEpisode, isPlaying, togglePlay } = useAudio();
const isCurrent = currentEpisode?._id === episode._id;
const podcast = typeof episode.podcastId === 'object' ? episode.podcastId : null;
const pubDate = new Date(episode.pubDate).toLocaleDateString('fr-FR', {
month: 'short',
day: 'numeric',
year: 'numeric'
month: 'short', day: 'numeric', year: 'numeric'
});
const durationMin = Math.floor(episode.duration / 60);
const durationMinutes = Math.floor(episode.duration / 60);
const progressPct = episode.progress && episode.duration > 0
? (episode.progress.position / episode.duration) * 100 : 0;
const isCompleted = episode.progress?.isCompleted === true || progressPct >= 90;
const isInProgress = !isCompleted && episode.progress && episode.progress.position > 0;
// Badges — consider completed if flagged OR if ≥90% listened
const progressPercent =
episode.progress && episode.duration > 0
? episode.progress.position / episode.duration
: 0;
const isCompleted =
episode.progress?.isCompleted === true || progressPercent >= 0.9;
const isNew =
!episode.progress &&
Date.now() - new Date(episode.pubDate).getTime() < 7 * 24 * 60 * 60 * 1000;
const handleDetailsClick = (e: React.MouseEvent) => {
if (onDetails) {
e.stopPropagation();
onDetails(episode);
}
};
const handlePlayClick = (e: React.MouseEvent) => {
const handleClick = () => onDetails?.(episode);
const handlePlay = (e: React.MouseEvent) => {
e.stopPropagation();
if (isCurrent) {
togglePlay();
} else {
onPlay(episode);
}
isCurrent ? togglePlay() : onPlay(episode);
};
const handleToggleRead = (e: React.MouseEvent) => {
e.stopPropagation();
if (onToggleRead) onToggleRead(episode, !isCompleted);
onToggleRead?.(episode, !isCompleted);
};
return (
<div
className="group premium-card premium-glass rounded-[var(--radius-card)] p-4 hover:bg-[var(--bg-secondary)] transition-all duration-500 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/30 flex flex-col h-full cursor-pointer relative"
onClick={handleDetailsClick}
className="card rounded-[var(--radius-lg)] overflow-hidden flex flex-col h-full cursor-pointer group relative"
onClick={handleClick}
>
{/* Visual Header */}
<div className="relative aspect-square rounded-xl overflow-hidden mb-5 shadow-xl border border-[var(--border-color)]">
{/* Image */}
<div className="relative aspect-square overflow-hidden">
{(episode.imageUrl || podcast?.imageUrl) ? (
<img
src={episode.imageUrl || podcast!.imageUrl}
alt={episode.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<img src={episode.imageUrl || podcast!.imageUrl} alt={episode.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
) : (
<div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-4xl">🎙️</div>
<div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-3xl">🎙️</div>
)}
{/* Status Badges - top left */}
<div className="absolute top-3 left-3 flex items-center gap-2 z-10">
{/* Top left: completed badge */}
<div className="absolute top-3 left-3">
{isCompleted && (
<div className="px-2.5 py-1 rounded-lg bg-emerald-500/90 backdrop-blur-md border border-emerald-300/30 text-[9px] font-black text-white uppercase tracking-widest flex items-center gap-1 shadow-lg">
<span className="px-2 py-1 rounded-md bg-[var(--accent-emerald)]/90 backdrop-blur-md text-white text-[0.6rem] font-extrabold uppercase tracking-wider border border-emerald-300/20">
LU
</div>
</span>
)}
</div>
{/* Hover Play Button Overlay */}
<div className={`absolute inset-0 bg-black/40 transition-opacity duration-300 flex items-center justify-center backdrop-blur-[2px] ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
{/* Hover play overlay */}
<div className={`absolute inset-0 bg-black/50 transition-opacity duration-300 flex items-center justify-center ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
<div
onClick={handlePlayClick}
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 active:scale-95"
onClick={handlePlay}
className="w-14 h-14 rounded-full bg-white/90 text-[var(--bg-base)] flex items-center justify-center shadow-xl transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300 active:scale-95"
>
{isCurrent && isPlaying
? <Pause className="w-6 h-6 fill-current" />
: <Play className="w-6 h-6 fill-current ml-1" />
}
? <Pause className="w-5 h-5 fill-current" />
: <Play className="w-5 h-5 fill-current ml-0.5" />}
</div>
</div>
{/* Duration/Status Badge */}
<div className="absolute bottom-3 right-3 flex items-center gap-2">
{isCurrent && (
<div className="px-2 py-1 rounded-lg bg-[var(--accent-primary)] border border-[var(--accent-primary)]/20 text-[9px] font-black text-white uppercase tracking-widest flex items-center gap-1.5 shadow-glow-indigo animate-pulse">
<Activity className="w-3 h-3" />
{isPlaying ? "EN COURS" : "EN PAUSE"}
</div>
)}
<div className="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>
{/* Bottom right: now playing + duration */}
<div className="absolute bottom-3 right-3 flex items-center gap-1.5">
{isCurrent && (
<span className={`px-2 py-1 rounded-md text-white text-[0.6rem] font-extrabold uppercase tracking-wider flex items-center gap-1 border animate-pulse ${
isPlaying
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)]/30'
: 'bg-[var(--bg-elevated)] border-[var(--border-hover)]'
}`}>
<Activity className="w-3 h-3" />
{isPlaying ? 'En cours' : 'Pause'}
</span>
)}
<span className="px-2 py-1 rounded-md bg-black/60 backdrop-blur-md text-white text-[0.6rem] font-bold uppercase tracking-wider border border-white/10">
{durationMin} min
</span>
</div>
</div>
{/* Content */}
<div className="flex-1 flex flex-col">
<div className="flex items-center gap-2 mb-2">
<span className="text-[9px] font-black text-[var(--accent-primary)] uppercase tracking-[0.2em] truncate flex-1">
{podcast?.author || ''}
</span>
</div>
<h3 className="text-sm font-bold line-clamp-2 mb-4 leading-relaxed group-hover:text-[var(--accent-primary)] transition-colors flex-1">
<div className="flex-1 flex flex-col p-4">
<p className="text-[0.65rem] font-bold text-[var(--accent-primary)] uppercase tracking-wider truncate mb-1">
{podcast?.author || ''}
</p>
<h3 className="text-sm font-bold leading-snug line-clamp-2 mb-3 flex-1 group-hover:text-[var(--accent-primary)] transition-colors">
{episode.title}
</h3>
{/* Footer Meta */}
<div className="flex items-center justify-between pt-4 border-t border-[var(--border-color)] text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest">
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
<span>{pubDate}</span>
</div>
{onToggleRead && (
<button
onClick={handleToggleRead}
title={isCompleted ? 'Marquer comme non lu' : 'Marquer comme lu'}
className={`flex items-center gap-1 px-2 py-1 rounded-lg transition-all duration-200 border ${
isCompleted
? 'text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10'
: 'text-[var(--text-secondary)] border-transparent hover:text-emerald-400 hover:border-emerald-500/30 hover:bg-emerald-500/10'
}`}
>
{isCompleted
? <CheckCircle2 className="w-3.5 h-3.5" />
: <Circle className="w-3.5 h-3.5" />
}
<span>{isCompleted ? 'Lu' : 'Marquer'}</span>
</button>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-color)] text-[0.65rem] font-semibold text-[var(--text-secondary)] uppercase tracking-wider">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{pubDate}
</span>
{onToggleRead && (
<button onClick={handleToggleRead}
className={`flex items-center gap-1 px-2 py-1 rounded-md transition-all border ${
isCompleted
? 'text-[var(--accent-emerald)] border-[var(--accent-emerald)]/30 hover:bg-[var(--accent-emerald)]/10'
: 'text-[var(--text-muted)] border-transparent hover:text-[var(--accent-emerald)] hover:border-[var(--accent-emerald)]/30 hover:bg-[var(--accent-emerald)]/10'
}`}>
{isCompleted ? <CheckCircle2 className="w-3.5 h-3.5" /> : <Circle className="w-3.5 h-3.5" />}
<span>{isCompleted ? 'Lu' : 'Marquer lu'}</span>
</button>
)}
</div>
</div>
{/* Local Progress Bar */}
{episode.progress && !isCompleted && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/5 rounded-b-[2rem] overflow-hidden">
<div
className="h-full bg-[var(--accent-primary)] shadow-glow-indigo transition-all duration-1000"
style={{ width: `${Math.min(100, (episode.progress.position / episode.duration) * 100)}%` }}
/>
{/* Progress bar */}
{isInProgress && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-[var(--border-color)]">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] transition-all duration-500"
style={{ width: `${Math.min(100, progressPct)}%` }} />
</div>
)}
</div>
@@ -1,4 +1,3 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { X, Play, Clock, Calendar, BarChart2 } from 'lucide-react';
import { Episode } from '../services/episodeService';
+39 -37
View File
@@ -1,5 +1,4 @@
import { Link } from 'react-router-dom';
import { LogOut, User as UserIcon, Sun, Moon } from 'lucide-react';
import { LogOut, Sun, Moon } from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
interface HeaderProps {
@@ -13,57 +12,60 @@ export default function Header({ title, subtitle, user, onLogout }: HeaderProps)
const { theme, toggleTheme } = useTheme();
return (
<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-[var(--accent-glow)] border border-[var(--border-color)] text-[10px] font-bold text-[var(--accent-primary)] uppercase tracking-widest mb-3">
{subtitle || "Exploration Audio"}
</div>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-display font-extrabold tracking-tight text-balance leading-tight">
<header className="mb-10">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
{subtitle && (
<span className="badge badge-accent mb-3">
{subtitle}
</span>
)}
<h1 className="text-3xl sm:text-4xl lg:text-5xl text-balance">
{title}
</h1>
</div>
{/* User Profile + Theme Toggle */}
<div className="flex items-center gap-4 sm:gap-6">
{/* Theme Toggle */}
{/* Right side: theme toggle + user */}
<div className="flex items-center gap-3 shrink-0">
<button
onClick={toggleTheme}
className="p-3 rounded-2xl bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:border-[var(--accent-primary)] transition-all shadow-lg group-theme-toggle"
title="Changer de thème"
className="w-10 h-10 rounded-xl flex items-center justify-center bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-hover)] transition-all lg:hidden"
aria-label="Changer le thème"
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{user && (
<div className="flex items-center gap-4 sm:gap-6">
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm font-bold tracking-wide">{user.username}</span>
<span className="text-[10px] font-medium text-[var(--text-secondary)] uppercase tracking-widest">Membre</span>
</div>
<div className="relative group/profile">
<div className="w-12 h-12 rounded-2xl p-[1px] bg-gradient-to-tr from-[var(--accent-primary)] to-[var(--accent-secondary)] transition-all">
<div className="w-full h-full rounded-2xl bg-[var(--bg-primary)] 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-[var(--accent-primary)]">{user.username.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="flex items-center gap-3">
<div className="hidden sm:block text-right">
<p className="text-sm font-semibold leading-tight">{user.username}</p>
<p className="text-[0.6rem] text-[var(--text-muted)] font-semibold uppercase tracking-widest">Membre</p>
</div>
<div className="relative group">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] p-[2px]">
<div className="w-full h-full rounded-[10px] bg-[var(--bg-base)] flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-base font-bold text-[var(--accent-primary)]">
{user.username.charAt(0).toUpperCase()}
</span>
)}
</div>
</div>
{/* Profile Popup (Minimal) */}
{onLogout && (
{onLogout && (
<div className="absolute right-0 top-full mt-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<button
onClick={onLogout}
className="absolute top-full right-0 mt-4 opacity-0 scale-90 translate-y-2 group-hover/profile:opacity-100 group-hover/profile:scale-100 group-hover/profile:translate-y-0 transition-all duration-300 premium-glass px-4 py-2 flex items-center gap-2 hover:bg-[var(--accent-primary)]/10"
className="glass rounded-[var(--radius-md)] px-4 py-2.5 flex items-center gap-2 text-sm font-semibold text-[var(--text-secondary)] hover:text-[var(--accent-rose)] whitespace-nowrap transition-colors"
>
<LogOut className="w-4 h-4 text-accent-rose" />
<span className="text-xs font-bold whitespace-nowrap">Logout</span>
<LogOut className="w-4 h-4" />
Déconnexion
</button>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
+66 -68
View File
@@ -1,5 +1,5 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Flame, PlusCircle, Library, Sun, Moon } from 'lucide-react';
import { Home, Flame, PlusCircle, Library, Sun, Moon, Headphones } from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
export default function Navigation() {
@@ -9,90 +9,88 @@ export default function Navigation() {
const isActive = (path: string) => location.pathname === path;
const navItems = [
{ path: '/dashboard', label: 'Tableau de bord', icon: Home },
{ path: '/trending', label: 'Tendances', icon: Flame },
{ path: '/library', label: 'Bibliothèque', icon: Library },
{ path: '/add', label: 'Ajouter', icon: PlusCircle },
{ path: '/dashboard', label: 'Accueil', icon: Home },
{ path: '/trending', label: 'Tendances', icon: Flame },
{ path: '/library', label: 'Bibliothèque', icon: Library },
{ path: '/add', label: 'Explorer', icon: PlusCircle },
];
return (
<>
{/* 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-[var(--radius-panel)] border border-[var(--border-color)]">
{/* Logo */}
<Link to="/" className="flex items-center gap-4 group">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] 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 tracking-tight">PODCASTIC</span>
<span className="text-[10px] text-[var(--text-secondary)] font-bold uppercase tracking-[0.2em]">Your audio space</span>
</div>
</Link>
{/* ── Desktop Sidebar ── */}
<aside className="fixed left-0 top-0 bottom-0 w-72 z-40 hidden lg:flex flex-col p-6">
<div className="glass rounded-[var(--radius-xl)] h-full flex flex-col p-6">
{/* Navigation Links */}
<div className="flex-1 space-y-2">
{/* Logo */}
<Link to="/" className="flex items-center gap-3 mb-10 group">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-xl shadow-lg shadow-[var(--accent-primary)]/20 group-hover:scale-105 transition-transform">
<Headphones className="w-5 h-5 text-white" />
</div>
<div>
<span className="text-lg font-display font-extrabold tracking-tight leading-none block">PODCASTIC</span>
<span className="text-[0.6rem] text-[var(--text-muted)] font-semibold uppercase tracking-[0.15em]">Premium Audio</span>
</div>
</Link>
{/* Nav links */}
<nav className="flex-1 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all duration-200 group ${
active
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]'
}`}
>
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${active ? 'text-[var(--accent-primary)]' : ''}`} />
<span>{item.label}</span>
{active && (
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-[var(--accent-primary)] shadow-[0_0_8px_var(--accent-glow)]" />
)}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="pt-4 mt-4 border-t border-[var(--border-color)] flex items-center justify-between">
<span className="text-[0.6rem] text-[var(--text-muted)] font-semibold uppercase tracking-widest">v2.0</span>
<button
onClick={toggleTheme}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)] transition-all"
aria-label="Changer le thème"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
</div>
</div>
</aside>
{/* ── Mobile Bottom Nav ── */}
<nav className="fixed bottom-4 left-3 right-3 z-50 lg:hidden">
<div className="glass rounded-[var(--radius-lg)] flex items-center justify-around px-2 py-3 max-w-md mx-auto shadow-2xl">
{navItems.map((item) => {
const ActiveIcon = item.icon;
const Icon = 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-xl transition-all duration-300 ${
active
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)] shadow-sm'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
className={`flex flex-col items-center gap-1 transition-all duration-200 px-3 py-1 rounded-lg ${
active ? 'text-[var(--accent-primary)]' : 'text-[var(--text-secondary)]'
}`}
>
{active && (
<div className="absolute left-0 w-1.5 h-6 rounded-full bg-[var(--accent-primary)] shadow-glow-indigo" />
)}
<ActiveIcon className={`w-5 h-5 transition-transform group-hover:scale-110 ${active ? 'text-[var(--accent-primary)]' : ''}`} />
<span className="text-sm font-bold tracking-wide">{item.label}</span>
<Icon className="w-6 h-6" />
<span className="text-[0.55rem] font-bold uppercase tracking-wider">{item.label}</span>
</Link>
);
})}
</div>
{/* Footer — theme toggle + version */}
<div className="mt-auto pt-6 border-t border-[var(--border-color)] flex items-center justify-between">
<p className="px-2 text-[10px] text-[var(--text-secondary)] font-bold uppercase tracking-widest opacity-50">
Podcastic v1.0
</p>
<button
onClick={toggleTheme}
aria-label="Changer de thème"
className="w-9 h-9 rounded-xl flex items-center justify-center transition-all duration-300
hover:bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]
border border-transparent hover:border-[var(--border-color)]"
>
{theme === 'dark'
? <Sun className="w-4 h-4" />
: <Moon className="w-4 h-4" />
}
</button>
</div>
</nav>
{/* Mobile Bottom Nav */}
<nav className="fixed bottom-6 left-4 right-4 sm:left-6 sm:right-6 lg:hidden z-50 h-20 premium-glass flex items-center justify-around px-2 sm:px-4 rounded-[var(--radius-card)] shadow-2xl border border-[var(--border-color)] max-w-lg mx-auto">
{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-[var(--accent-primary)]' : 'text-[var(--text-secondary)]'
}`}
>
<MobileIcon className="w-6 h-6" />
{active && <div className="w-1 h-1 rounded-full bg-[var(--accent-primary)] shadow-glow-indigo" />}
</Link>
);
})}
</nav>
</>
);
+86 -91
View File
@@ -1,5 +1,4 @@
import React from 'react';
import { Play, Plus, Check, Loader, Trash2, RefreshCw } from 'lucide-react';
import { Play, Plus, Check, Loader, Trash2, RefreshCw, Calendar } from 'lucide-react';
interface PodcastCardProps {
title: string;
@@ -8,158 +7,154 @@ interface PodcastCardProps {
rank?: number;
episodeCount?: number;
language?: string;
description?: string;
lastEpisodeTitle?: string;
lastEpisodeDate?: string;
// Subscribe action
// Subscribe
onSubscribe?: () => void;
isSubscribed?: boolean;
isSubscribing?: boolean;
// Library actions
// Library
onSync?: () => void;
isSyncing?: boolean;
onUnsubscribe?: () => void;
confirmDelete?: boolean;
onConfirmDelete?: () => void;
onCancelDelete?: () => void;
// Navigation
onClick?: () => void;
}
export default function PodcastCard({
title,
author,
imageUrl,
rank,
episodeCount,
language,
lastEpisodeTitle,
lastEpisodeDate,
onSubscribe,
isSubscribed,
isSubscribing,
onSync,
isSyncing,
onUnsubscribe,
confirmDelete,
onConfirmDelete,
onCancelDelete,
title, author, imageUrl, rank, episodeCount, language, description,
lastEpisodeTitle, lastEpisodeDate,
onSubscribe, isSubscribed, isSubscribing,
onSync, isSyncing, onUnsubscribe,
confirmDelete, onConfirmDelete, onCancelDelete,
onClick,
}: PodcastCardProps) {
return (
<div className="group premium-card premium-glass rounded-[var(--radius-card)] p-4 hover:bg-[var(--bg-secondary)] transition-all duration-500 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/30 flex flex-col h-full relative">
<div
className="card rounded-[var(--radius-lg)] overflow-hidden flex flex-col h-full cursor-pointer group"
onClick={onClick}
>
{/* Image */}
<div className="relative aspect-square rounded-xl overflow-hidden mb-4 shadow-xl border border-[var(--border-color)]">
<div className="relative aspect-square overflow-hidden">
{imageUrl ? (
<img
src={imageUrl}
alt={title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<img src={imageUrl} alt={title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
) : (
<div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-4xl">🎙️</div>
)}
{rank && (
<div className="absolute top-3 left-3 px-2.5 py-1 rounded-lg bg-black/60 backdrop-blur-md border border-white/10 text-[9px] font-black text-white uppercase tracking-widest shadow-lg z-10">
#{rank}
</div>
)}
{isSubscribed && (
<div className="absolute top-3 right-3 px-2.5 py-1 rounded-lg bg-emerald-500/90 backdrop-blur-md border border-emerald-300/30 text-[9px] font-black text-white uppercase tracking-widest flex items-center gap-1 shadow-lg z-10">
<Check className="w-3 h-3" />
Abonné
</div>
)}
{/* Hover play overlay */}
<div className="absolute inset-0 bg-black/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" />
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<div className="w-14 h-14 rounded-full bg-white/90 text-[var(--bg-base)] flex items-center justify-center shadow-xl transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300">
<Play className="w-5 h-5 fill-current ml-0.5" />
</div>
</div>
{(episodeCount !== undefined || language) && (
<div className="absolute bottom-3 left-3 flex items-center gap-1.5 z-10">
{episodeCount !== undefined && (
<div className="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">
{episodeCount} ép.
</div>
)}
{language && (
<div className="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">
{language}
</div>
{/* Top badges */}
<div className="absolute top-3 left-3 right-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{rank && (
<span className="px-2 py-1 rounded-md bg-black/60 backdrop-blur-md text-white text-[0.6rem] font-extrabold uppercase tracking-wider border border-white/10">
#{rank}
</span>
)}
</div>
)}
{isSubscribed && (
<span className="px-2 py-1 rounded-md bg-[var(--accent-emerald)]/90 backdrop-blur-md text-white text-[0.6rem] font-extrabold uppercase tracking-wider flex items-center gap-1 border border-emerald-300/20">
<Check className="w-3 h-3" /> Abonné
</span>
)}
</div>
{/* Bottom badges */}
<div className="absolute bottom-3 left-3 flex items-center gap-1.5">
{episodeCount !== undefined && (
<span className="px-2 py-1 rounded-md bg-black/60 backdrop-blur-md text-white text-[0.6rem] font-bold uppercase tracking-wider border border-white/10">
{episodeCount} ép.
</span>
)}
{language && (
<span className="px-2 py-1 rounded-md bg-black/60 backdrop-blur-md text-white text-[0.6rem] font-bold uppercase tracking-wider border border-white/10">
{language}
</span>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 flex flex-col">
<div className="flex-1 flex flex-col p-4">
{author && (
<span className="text-[9px] font-black text-[var(--accent-primary)] uppercase tracking-[0.2em] truncate mb-1">
<p className="text-[0.65rem] font-bold text-[var(--accent-primary)] uppercase tracking-wider truncate mb-1">
{author}
</span>
</p>
)}
<h3 className="text-sm font-bold line-clamp-2 leading-relaxed group-hover:text-[var(--accent-primary)] transition-colors flex-1 mb-4">
<h3 className="text-sm font-bold leading-snug line-clamp-2 mb-3 group-hover:text-[var(--accent-primary)] transition-colors flex-1">
{title}
</h3>
{description && (
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 leading-relaxed">{description}</p>
)}
{/* Last episode preview */}
{lastEpisodeTitle && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-3 mb-4">
<p className="text-[9px] font-black text-[var(--accent-primary)] uppercase tracking-[0.15em] mb-1">Dernier épisode</p>
<p className="text-xs font-bold line-clamp-1">{lastEpisodeTitle}</p>
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-[var(--radius-sm)] p-2.5 mb-3">
<p className="text-[0.6rem] font-bold text-[var(--text-muted)] uppercase tracking-wider mb-0.5">Dernier épisode</p>
<p className="text-xs font-semibold line-clamp-1">{lastEpisodeTitle}</p>
{lastEpisodeDate && (
<p className="text-[9px] text-[var(--text-secondary)] mt-0.5">
<p className="text-[0.6rem] text-[var(--text-muted)] mt-1 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(lastEpisodeDate).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
)}
</div>
)}
{/* Footer */}
{/* Actions */}
<div className="pt-3 border-t border-[var(--border-color)] mt-auto">
{confirmDelete ? (
<div className="flex items-center gap-2 animate-fade-in">
<button onClick={onConfirmDelete} className="flex-1 py-2 bg-accent-rose text-white rounded-xl text-[10px] font-black uppercase hover:bg-accent-rose/80 transition-all">Confirmer</button>
<button onClick={onCancelDelete} className="flex-1 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-xl text-[10px] font-black uppercase hover:bg-[var(--bg-primary)] transition-all">Annuler</button>
<button onClick={(e) => { e.stopPropagation(); onConfirmDelete?.(); }}
className="flex-1 py-2 rounded-lg bg-[var(--accent-rose)] text-white text-[0.65rem] font-bold uppercase tracking-wider hover:bg-[var(--accent-rose)]/80 transition-all">
Confirmer
</button>
<button onClick={(e) => { e.stopPropagation(); onCancelDelete?.(); }}
className="flex-1 py-2 rounded-lg bg-[var(--bg-surface)] text-[var(--text-secondary)] text-[0.65rem] font-bold uppercase tracking-wider hover:bg-[var(--bg-elevated)] transition-all border border-[var(--border-color)]">
Annuler
</button>
</div>
) : onUnsubscribe ? (
<div className="flex items-center justify-end gap-4">
<div className="flex items-center justify-end gap-3">
{onSync && (
<button
onClick={onSync}
<button onClick={(e) => { e.stopPropagation(); onSync(); }}
disabled={isSyncing}
className="flex items-center gap-1.5 text-[10px] font-bold text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-widest disabled:opacity-50"
>
className="btn-ghost text-[0.65rem]">
{isSyncing ? <Loader className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
Resync
</button>
)}
<button
onClick={onUnsubscribe}
className="flex items-center gap-1.5 text-[10px] font-bold text-[var(--text-secondary)] hover:text-accent-rose transition-all uppercase tracking-widest"
>
<button onClick={(e) => { e.stopPropagation(); onUnsubscribe(); }}
className="btn-ghost text-[0.65rem] !text-[var(--accent-rose)] hover:!text-[var(--accent-rose)]">
<Trash2 className="w-3 h-3" />
Désabonner
Retirer
</button>
</div>
) : onSubscribe ? (
<button
onClick={onSubscribe}
onClick={(e) => { e.stopPropagation(); onSubscribe(); }}
disabled={isSubscribing || isSubscribed}
className={`w-full py-2.5 rounded-xl font-bold uppercase tracking-widest text-[10px] transition-all flex items-center justify-center gap-2 ${
className={`w-full py-2.5 rounded-lg font-bold text-[0.65rem] uppercase tracking-wider transition-all flex items-center justify-center gap-2 ${
isSubscribed
? 'bg-[var(--accent-secondary)]/10 text-[var(--accent-secondary)] border border-[var(--accent-secondary)]/20'
: 'bg-[var(--text-primary)] text-[var(--bg-primary)] hover:bg-[var(--accent-primary)] hover:text-white active:scale-95'
}`}
>
{isSubscribing ? (
<Loader className="w-3.5 h-3.5 animate-spin" />
) : isSubscribed ? (
<><Check className="w-3.5 h-3.5" /> Abonné</>
) : (
<><Plus className="w-3.5 h-3.5" /> S'abonner</>
)}
? 'bg-[var(--accent-emerald)]/10 text-[var(--accent-emerald)] border border-[var(--accent-emerald)]/20'
: 'bg-[var(--text-primary)] text-[var(--bg-base)] hover:bg-[var(--accent-primary)] hover:text-white active:scale-[0.98]'
}`}>
{isSubscribing ? <Loader className="w-3.5 h-3.5 animate-spin" /> :
isSubscribed ? <><Check className="w-3.5 h-3.5" /> Abonné</> :
<><Plus className="w-3.5 h-3.5" /> S'abonner</>}
</button>
) : null}
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
import { CheckCircle2, ChevronRight, BookOpen, Sparkles, X } from 'lucide-react';
import { ChevronRight, BookOpen, Sparkles, X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { createPortal } from 'react-dom';
+113 -194
View File
@@ -6,13 +6,12 @@ import { episodeService, Episode } from '../services/episodeService';
import Header from '../components/Header';
import EpisodeCard from '../components/EpisodeCard';
import EpisodeDetails from '../components/EpisodeDetails';
import PodcastCard from '../components/PodcastCard';
import { useAudio } from '../context/AudioContext';
import AlertModal, { AlertType } from '../components/AlertModal';
import { Search, Loader, Rss, Globe, Sparkles, PlusCircle, LayoutGrid, ListMusic } from 'lucide-react';
import PodcastCard from '../components/PodcastCard';
import { authService } from '../services/authService';
import SuccessModal from '../components/SuccessModal';
import { authService } from '../services/authService';
import { Search, Loader, Rss, PlusCircle, LayoutGrid, ListMusic, Sparkles } from 'lucide-react';
export default function AddPodcast() {
const queryClient = useQueryClient();
@@ -23,47 +22,20 @@ export default function AddPodcast() {
const [subscribingId, setSubscribingId] = useState<string | null>(null);
const [rssUrl, setRssUrl] = useState('');
const [rssLoading, setRssLoading] = useState(false);
// Alert states
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean;
title: string;
message: string;
type: AlertType;
}>({
isOpen: false,
title: '',
message: '',
type: 'error'
});
const [rssSuccess, setRssSuccess] = useState<string | null>(null);
const [detailsEpisode, setDetailsEpisode] = useState<Episode | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const selectedGenre = ''; // Default to All categories
// Modal state
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successPodcast, setSuccessPodcast] = useState<any>(null);
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean; title: string; message: string; type: AlertType;
}>({ isOpen: false, title: '', message: '', type: 'error' });
useEffect(() => {
const loadUser = async () => {
try {
const response = await authService.getMe();
setUser(response.user);
} catch (error) {
console.error('Failed to load user:', error);
}
};
loadUser();
authService.getMe().then(r => setUser(r.user)).catch(() => {});
}, []);
const handleOpenDetails = (episode: Episode) => {
setDetailsEpisode(episode);
setIsDetailsOpen(true);
};
// Podcast Discovery Query
const { data: searchResults, isLoading: isSearching } = useQuery({
queryKey: ['discover', 'search', searchQuery],
queryFn: () => discoveryService.discoverPodcasts(searchQuery, 30),
@@ -71,18 +43,16 @@ export default function AddPodcast() {
staleTime: 5 * 60 * 1000,
});
// Episode Global Search Query
const { data: episodeResults, isLoading: isSearchingEpisodes } = useQuery({
queryKey: ['episodes', 'search', searchQuery],
queryFn: () => episodeService.searchEpisodes(searchQuery, 40),
enabled: searchQuery.length >= 2 && searchType === 'episodes',
staleTime: 2 * 60 * 1000,
queryKey: ['episodes', 'search', searchQuery],
queryFn: () => episodeService.searchEpisodes(searchQuery, 40),
enabled: searchQuery.length >= 2 && searchType === 'episodes',
staleTime: 2 * 60 * 1000,
});
// Trending discovery for the "Add" page when no search is active
const { data: trendingResults, isLoading: isTrendingLoading } = useQuery({
queryKey: ['discover', 'trending', selectedGenre],
queryFn: () => discoveryService.getTrendingPodcasts(18, selectedGenre),
const { data: trendingResults } = useQuery({
queryKey: ['discover', 'trending', ''],
queryFn: () => discoveryService.getTrendingPodcasts(18, ''),
enabled: searchQuery.length === 0 && searchType === 'podcasts',
staleTime: 30 * 60 * 1000,
});
@@ -92,20 +62,15 @@ export default function AddPodcast() {
queryFn: () => podcastService.getUserSubscriptions(),
});
const isSubscribed = (rssUrl: string) => {
return subsData?.podcasts.some(p => p.rssUrl === rssUrl);
};
const isSubscribed = (rssUrl: string) =>
subsData?.podcasts.some((p: any) => p.rssUrl === rssUrl);
const subscribeMutation = useMutation({
mutationFn: (podcast: DiscoveryPodcast) =>
discoveryService.subscribeFromDiscovery(podcast.rssUrl, {
title: podcast.title,
author: podcast.author,
imageUrl: podcast.imageUrl,
title: podcast.title, author: podcast.author, imageUrl: podcast.imageUrl,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
},
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] }); },
});
const handleSubscribe = async (podcast: DiscoveryPodcast) => {
@@ -115,11 +80,8 @@ export default function AddPodcast() {
setSuccessPodcast(podcast);
setShowSuccessModal(true);
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
} catch (error: any) {
console.error(error);
} finally {
setSubscribingId(null);
}
} catch { /* handled */ }
finally { setSubscribingId(null); }
};
const handleRssSubscribe = async (e: React.FormEvent) => {
@@ -129,120 +91,103 @@ export default function AddPodcast() {
setRssSuccess(null);
try {
const result = await podcastService.subscribe(rssUrl.trim());
setRssSuccess(`"${result.podcast.title}" ajouté !`);
setRssSuccess(`"${result.podcast.title}" ajouté avec succès !`);
setRssUrl('');
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
} catch (error: any) {
setAlertConfig({
isOpen: true,
title: "Échec de l'ajout",
message: error.response?.data?.message || "Impossible d'ajouter ce podcast. Vérifiez que l'URL du flux RSS est correcte et accessible.",
isOpen: true, title: "Flux inaccessible",
message: error.response?.data?.message || "Vérifiez que l'URL RSS est correcte et accessible.",
type: 'error'
});
} finally {
setRssLoading(false);
}
} finally { setRssLoading(false); }
};
return (
<div className="min-h-screen">
<Header
title="Exploration"
subtitle="DÉCOUVRIR"
user={user}
onLogout={() => {}}
/>
<Header title="Exploration" subtitle="Découvrir" user={user} onLogout={() => {}} />
<main>
{/* RSS Input Area */}
<div className="premium-glass p-8 rounded-[var(--radius-panel)] mb-12 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-[var(--accent-primary)]/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">Flux RSS Direct</h3>
<div>
{/* RSS Section */}
<div className="glass rounded-[var(--radius-xl)] p-8 mb-10 relative overflow-hidden">
<div className="absolute top-0 right-0 w-40 h-40 bg-[var(--accent-primary)]/5 blur-[50px] pointer-events-none" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]">
<Rss className="w-5 h-5" />
</div>
<form onSubmit={handleRssSubscribe} className="flex flex-col sm:flex-row gap-4">
<input
type="url"
placeholder="URL du flux RSS (ex: https://feed.podbean.com/...)"
value={rssUrl}
onChange={(e) => { setRssUrl(e.target.value); 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>
{rssSuccess && <p className="mt-4 text-xs font-bold text-accent-cyan animate-fade-in">✨ {rssSuccess}</p>}
</div>
<h3 className="text-lg font-display font-extrabold">Flux RSS direct</h3>
</div>
<form onSubmit={handleRssSubscribe} className="flex flex-col sm:flex-row gap-3">
<input type="url" placeholder="https://example.com/feed.xml" value={rssUrl}
onChange={e => { setRssUrl(e.target.value); setRssSuccess(null); }}
className="input flex-1" />
<button type="submit" disabled={rssLoading || !rssUrl.trim()}
className="btn-primary shrink-0 !bg-[var(--accent-primary)] !from-[var(--accent-primary)] !to-[var(--accent-primary)]">
{rssLoading ? <Loader className="w-4 h-4 animate-spin" /> : <PlusCircle className="w-4 h-4" />}
Ajouter
</button>
</form>
{rssSuccess && (
<p className="mt-3 text-xs font-semibold text-[var(--accent-emerald)] animate-fade-in flex items-center gap-1">
<Sparkles className="w-3 h-3" /> {rssSuccess}
</p>
)}
</div>
</div>
{/* Global Search Bar with Tabs */}
<div className="mb-12">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 mb-8">
<div className="flex items-center gap-4">
<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">Recherche Intelligente</h3>
{/* Search section */}
<div className="mb-10">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[var(--bg-surface)] flex items-center justify-center text-[var(--text-secondary)] border border-[var(--border-color)]">
<Search className="w-5 h-5" />
</div>
<h3 className="text-lg font-display font-extrabold">Recherche intelligente</h3>
</div>
{/* Tabs */}
<div className="flex p-1 rounded-2xl bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<button
onClick={() => setSearchType('podcasts')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${searchType === 'podcasts' ? 'bg-[var(--accent-primary)] text-white shadow-lg' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
>
<LayoutGrid className="w-3 h-3" />
Podcasts
</button>
<button
onClick={() => setSearchType('episodes')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${searchType === 'episodes' ? 'bg-[var(--accent-primary)] text-white shadow-lg' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
>
<ListMusic className="w-3 h-3" />
Épisodes
</button>
</div>
</div>
<div className="relative group">
<input
type="text"
placeholder={searchType === 'podcasts' ? "Recherchez un podcast par nom ou auteur..." : "Cherchez un sujet dans tous les épisodes..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input-premium !py-6 !pl-16 text-xl shadow-2xl transition-all group-hover:bg-[var(--bg-secondary)]"
/>
<Sparkles className="absolute left-6 top-1/2 -translate-y-1/2 w-6 h-6 text-slate-500 group-focus-within:text-[var(--accent-primary)] transition-colors" />
</div>
{/* Tabs */}
<div className="flex p-1 rounded-lg bg-[var(--bg-surface)] border border-[var(--border-color)]">
<button onClick={() => setSearchType('podcasts')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-[0.65rem] font-bold uppercase tracking-wider transition-all ${
searchType === 'podcasts'
? 'bg-[var(--accent-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}>
<LayoutGrid className="w-3.5 h-3.5" /> Podcasts
</button>
<button onClick={() => setSearchType('episodes')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-[0.65rem] font-bold uppercase tracking-wider transition-all ${
searchType === 'episodes'
? 'bg-[var(--accent-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}>
<ListMusic className="w-3.5 h-3.5" /> Épisodes
</button>
</div>
</div>
<div className="relative">
<input type="text"
placeholder={searchType === 'podcasts'
? "Recherchez un podcast par nom ou auteur..."
: "Cherchez un sujet dans tous les épisodes..."}
value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
className="input !py-5 !pl-14 !text-lg" />
<Search className="absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)]" />
</div>
</div>
{/* Dynamic Results Grid */}
{(isSearching || isSearchingEpisodes || isTrendingLoading) ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{[1,2,3,4,5,6].map(i => <div key={i} className="premium-glass rounded-[var(--radius-card)] h-64 animate-pulse opacity-50" />)}
{/* Results */}
{isSearching || isSearchingEpisodes ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[1,2,3,4,5,6].map(i => <div key={i} className="skeleton aspect-[3/4] rounded-[var(--radius-lg)]" />)}
</div>
) : searchType === 'podcasts' && (searchQuery.length > 0 ? searchResults?.podcasts : trendingResults?.podcasts) ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 pb-32">
{(searchQuery.length > 0 ? searchResults?.podcasts : trendingResults?.podcasts).map((podcast: any) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-32 stagger">
{(searchQuery.length > 0 ? searchResults!.podcasts : trendingResults!.podcasts).map((podcast: any) => (
<PodcastCard
key={podcast.id}
title={podcast.title}
author={podcast.author}
imageUrl={podcast.imageUrl}
key={podcast.id} title={podcast.title} author={podcast.author} imageUrl={podcast.imageUrl}
onSubscribe={() => handleSubscribe(podcast)}
isSubscribed={isSubscribed(podcast.rssUrl)}
isSubscribing={subscribingId === podcast.id}
@@ -250,53 +195,27 @@ export default function AddPodcast() {
))}
</div>
) : searchType === 'episodes' && episodeResults?.episodes ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 pb-32">
{episodeResults.episodes.map((episode) => (
<EpisodeCard
key={episode._id}
episode={episode}
onPlay={playEpisode}
onDetails={handleOpenDetails}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-32">
{episodeResults.episodes.map((ep: Episode) => (
<EpisodeCard key={ep._id} episode={ep} onPlay={playEpisode}
onDetails={(e) => { setDetailsEpisode(e); setIsDetailsOpen(true); }} />
))}
</div>
) : searchQuery.length >= 2 && !isSearching && !isSearchingEpisodes ? (
<div className="premium-glass p-20 rounded-[var(--radius-panel)] text-center max-w-lg mx-auto">
<Globe className="w-12 h-12 text-slate-700 mx-auto mb-6" />
<p className="font-bold uppercase tracking-widest text-sm mb-2">Aucun résultat sur iTunes FR</p>
<p className="text-[var(--text-secondary)] text-xs">Vérifiez l'orthographe ou essayez un nom d'auteur.</p>
) : searchQuery.length >= 2 ? (
<div className="glass rounded-[var(--radius-xl)] p-16 text-center max-w-lg mx-auto">
<Search className="w-10 h-10 text-[var(--text-muted)] mx-auto mb-4" />
<p className="text-sm font-bold text-[var(--text-secondary)] uppercase tracking-wider mb-1">Aucun résultat</p>
<p className="text-xs text-[var(--text-muted)]">Vérifiez l'orthographe ou essayez un nom d'auteur.</p>
</div>
) : (
<div className="text-center py-20 opacity-30">
<div className="p-8 inline-block rounded-full bg-white/[0.02] border border-white/5">
<Search className="w-12 h-12 text-slate-700" />
</div>
</div>
)}
</main>
) : null}
</div>
<EpisodeDetails
episode={detailsEpisode}
isOpen={isDetailsOpen}
onClose={() => setIsDetailsOpen(false)}
onPlay={playEpisode}
/>
<AlertModal
isOpen={alertConfig.isOpen}
title={alertConfig.title}
message={alertConfig.message}
type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))}
/>
<SuccessModal
isOpen={showSuccessModal}
onClose={() => setShowSuccessModal(false)}
podcastTitle={successPodcast?.title || ""}
podcastAuthor={successPodcast?.author || ""}
podcastImage={successPodcast?.imageUrl || ""}
/>
<EpisodeDetails episode={detailsEpisode} isOpen={isDetailsOpen} onClose={() => setIsDetailsOpen(false)} onPlay={playEpisode} />
<AlertModal isOpen={alertConfig.isOpen} title={alertConfig.title} message={alertConfig.message} type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
<SuccessModal isOpen={showSuccessModal} onClose={() => setShowSuccessModal(false)}
podcastTitle={successPodcast?.title || ""} podcastAuthor={successPodcast?.author || ""}
podcastImage={successPodcast?.imageUrl || ""} />
</div>
);
}
+112 -170
View File
@@ -9,7 +9,7 @@ import EpisodeDetails from '../components/EpisodeDetails';
import { useAudio } from '../context/AudioContext';
import AlertModal, { AlertType } from '../components/AlertModal';
import AudioPlayer from '../components/AudioPlayer';
import { Sparkles, Play, Clock, TrendingUp, Loader, RefreshCcw } from 'lucide-react';
import { Sparkles, Play, Clock, Loader, ChevronDown } from 'lucide-react';
export default function Dashboard() {
const { playEpisode, currentEpisode, closePlayer, setUserId } = useAudio();
@@ -23,18 +23,9 @@ export default function Dashboard() {
const [hasMore, setHasMore] = useState(true);
const LIMIT = 32;
// Alert states
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean;
title: string;
message: string;
type: AlertType;
}>({
isOpen: false,
title: '',
message: '',
type: 'error'
});
isOpen: boolean; title: string; message: string; type: AlertType;
}>({ isOpen: false, title: '', message: '', type: 'error' });
const handleOpenDetails = (episode: Episode) => {
setDetailsEpisode(episode);
@@ -43,7 +34,6 @@ export default function Dashboard() {
const handleToggleRead = async (episode: Episode, completed: boolean) => {
const newProgress = { position: completed ? episode.duration : 0, isCompleted: completed };
// Optimistic update: local state + all cached query pages
setEpisodes(prev =>
prev.map(ep => ep._id === episode._id ? { ...ep, progress: newProgress } : ep)
);
@@ -61,31 +51,23 @@ export default function Dashboard() {
);
try {
await episodeService.saveProgress(episode._id, newProgress.position, completed);
} catch (error) {
console.error('Failed to toggle read state:', error);
// Revert on error
} catch {
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
}
};
// Load user
useEffect(() => {
const loadUser = async () => {
try {
const response = await authService.getMe();
setUser(response.user);
setUserId(response.user._id);
} catch (error) {
console.error('Failed to load user:', error);
} finally {
setLoading(false);
}
const r = await authService.getMe();
setUser(r.user);
setUserId(r.user._id);
} catch { /* ignore */ }
finally { setLoading(false); }
};
loadUser();
}, []);
// Fetch episodes with pagination
const { data: episodesData, isLoading: episodesLoading, isFetching: episodesFetching } = useQuery({
queryKey: ['episodes', 'latest', skip],
queryFn: () => episodeService.getLatestEpisodes(LIMIT, skip),
@@ -99,11 +81,10 @@ export default function Dashboard() {
setEpisodes(episodesData.episodes);
} else {
setEpisodes(prev => {
// Prevent duplicates
const newEpisodes = episodesData.episodes.filter(
(newEp: any) => !prev.some(oldEp => oldEp._id === newEp._id)
const fresh = episodesData.episodes.filter(
(e: any) => !prev.some(p => p._id === e._id)
);
return [...prev, ...newEpisodes];
return [...prev, ...fresh];
});
}
setHasMore(episodesData.episodes.length === LIMIT);
@@ -111,9 +92,7 @@ export default function Dashboard() {
}, [episodesData, skip]);
const loadMore = () => {
if (hasMore && !episodesFetching) {
setSkip(prev => prev + LIMIT);
}
if (hasMore && !episodesFetching) setSkip(prev => prev + LIMIT);
};
const handleLogout = async () => {
@@ -121,18 +100,14 @@ export default function Dashboard() {
await authService.logout();
authService.clearTokens();
window.location.href = '/login';
} catch (error) {
} catch {
setAlertConfig({
isOpen: true,
title: "Erreur de déconnexion",
message: "Une erreur s'est produite lors de la déconnexion. Veuillez rafraîchir la page.",
type: 'error'
isOpen: true, title: "Erreur", message: "Impossible de se déconnecter.", type: 'error'
});
}
};
// Episodes in progress: has progress, not completed, not ≥90%
const episodesInProgress = episodes?.filter(e => {
const inProgress = episodes?.filter(e => {
if (!e.progress || e.progress.isCompleted) return false;
if (e.duration > 0 && e.progress.position / e.duration >= 0.9) return false;
return e.progress.position > 0;
@@ -141,7 +116,7 @@ export default function Dashboard() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-12 h-12 rounded-full border-4 border-[var(--border-color)] border-t-[var(--accent-primary)] animate-spin" />
<div className="w-10 h-10 rounded-full border-[3px] border-[var(--border-color)] border-t-[var(--accent-primary)] animate-spin" />
</div>
);
}
@@ -149,154 +124,121 @@ export default function Dashboard() {
return (
<div className="min-h-screen">
<Header
title={`Bon retour, ${user?.username?.split(' ')[0] || 'Ami'} !`}
subtitle="VOTRE TABLEAU DE BORD"
title={`Bon retour, ${user?.username?.split(' ')[0] || 'ami'} !`}
subtitle="Votre tableau de bord"
user={user}
onLogout={handleLogout}
/>
<main className="">
{/* Welcome Section / Hero — becomes audio player when playing */}
<div>
{/* ── Hero / Now Playing ── */}
{currentEpisode ? (
<div className="mb-12">
<AudioPlayer
episode={currentEpisode}
onClose={closePlayer}
userId={user?._id}
mode="inline"
/>
<AudioPlayer episode={currentEpisode} onClose={closePlayer} userId={user?._id} mode="inline" />
</div>
) : (
<div className="premium-glass rounded-[var(--radius-panel)] p-8 lg:p-12 mb-12 relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-64 bg-[var(--accent-glow)] blur-[80px] -mr-32 -mt-32 group-hover:bg-[var(--accent-primary)]/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-[var(--accent-glow)] border border-[var(--border-color)] text-[10px] font-black text-[var(--accent-primary)] uppercase tracking-widest mb-4">
<Sparkles className="w-3 h-3" />
Podcastic
</div>
<h2 className="text-4xl lg:text-5xl font-display font-black mb-4 tracking-tight leading-tight max-w-2xl text-[var(--text-primary)]">
Découvrez les nouveautés de votre univers audio.
<div className="glass rounded-[var(--radius-xl)] p-8 lg:p-12 mb-12 relative overflow-hidden group">
<div className="absolute top-0 right-0 w-72 h-72 bg-[var(--accent-primary)]/10 blur-[80px] -mr-20 -mt-20 pointer-events-none" />
<div className="absolute bottom-0 left-1/2 w-96 h-48 bg-[var(--accent-secondary)]/5 blur-[60px] pointer-events-none" />
<div className="relative z-10">
<span className="badge badge-accent mb-4">
<Sparkles className="w-3 h-3" /> Podcastic
</span>
<h2 className="text-3xl lg:text-4xl xl:text-5xl mb-4 max-w-2xl">
Découvrez les nouveautés de votre univers audio.
</h2>
<p className="text-[var(--text-secondary)] text-lg mb-8 max-w-xl leading-relaxed font-medium">
Vous avez des nouveaux épisodes qui n'attendent que vous. Prêt pour une immersion ?
<p className="text-[var(--text-secondary)] text-base lg:text-lg mb-8 max-w-xl leading-relaxed font-medium">
Vos nouveaux épisodes n'attendent que vous. Prêt pour l'immersion ?
</p>
<div className="flex flex-wrap gap-4">
{episodesInProgress.length > 0 ? (
<button
onClick={() => playEpisode(episodesInProgress[0])}
className="neon-button flex items-center gap-2"
>
<Play className="w-4 h-4 fill-current" />
Reprendre : {episodesInProgress[0].title.substring(0, 20)}...
</button>
) : (
<Link to="/add" className="neon-button flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Découvrir des podcasts
</Link>
)}
<Link to="/trending" className="px-6 py-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] font-bold text-sm hover:bg-[var(--accent-primary)]/10 transition-all border border-[var(--border-color)] flex items-center justify-center">
Parcourir les tendances
</Link>
<div className="flex flex-wrap gap-3">
{inProgress.length > 0 ? (
<button onClick={() => playEpisode(inProgress[0])} className="btn-primary">
<Play className="w-4 h-4 fill-current" />
Reprendre : {inProgress[0].title.substring(0, 24)}...
</button>
) : (
<Link to="/add" className="btn-primary">
<Sparkles className="w-4 h-4" />
Découvrir des podcasts
</Link>
)}
<Link to="/trending" className="btn-secondary">
Parcourir les tendances
</Link>
</div>
</div>
</div>
)}
{/* Continue Listening Section (Only if progression exists) */}
{episodesInProgress.length > 0 && (
<div className="mb-12 animate-fade-in">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-glow)] flex items-center justify-center text-[var(--accent-primary)] shadow-glow-indigo">
<Play className="w-5 h-5 fill-current" />
</div>
<h3 className="text-2xl font-display font-black">Continuer l'écoute</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{episodesInProgress.slice(0, 4).map((episode) => (
<EpisodeCard
key={`progress-${episode._id}`}
episode={episode}
onPlay={playEpisode}
onDetails={handleOpenDetails}
onToggleRead={handleToggleRead}
/>
))}
</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-[var(--accent-glow)] flex items-center justify-center text-[var(--accent-primary)]">
<Clock className="w-5 h-5" />
{/* ── Continuer l'écoute ── */}
{inProgress.length > 0 && (
<section className="mb-12">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]">
<Play className="w-5 h-5 fill-current" />
</div>
<h3 className="text-2xl font-display font-black">Récemment Publiés</h3>
</div>
<span className="text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest px-3 py-1 rounded-lg border border-[var(--border-color)]">{episodes.length} CHARGÉS</span>
</div>
{episodesLoading && skip === 0 ? (
<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-[var(--radius-card)] h-80 shimmer" />
))}
</div>
) : episodes.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 mb-16 stagger-grid">
{episodes.map((episode) => (
<EpisodeCard
key={`${skip}-${episode._id}`}
episode={episode}
onPlay={playEpisode}
onDetails={handleOpenDetails}
onToggleRead={handleToggleRead}
/>
<h3 className="text-xl lg:text-2xl">Continuer l'écoute</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 stagger">
{inProgress.slice(0, 4).map(ep => (
<EpisodeCard key={`cp-${ep._id}`} episode={ep} onPlay={playEpisode} onDetails={handleOpenDetails} onToggleRead={handleToggleRead} />
))}
</div>
{hasMore && (
<div className="flex justify-center pb-20">
<button
onClick={loadMore}
disabled={episodesFetching}
className="px-10 py-4 rounded-2xl bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[10px] font-black uppercase tracking-[0.2em] hover:bg-[var(--accent-primary)]/10 hover:border-[var(--accent-primary)] transition-all flex items-center gap-3"
>
{episodesFetching ? <Loader className="w-4 h-4 animate-spin" /> : <RefreshCcw className="w-4 h-4" />}
{episodesFetching ? 'Chargement...' : 'Charger la suite'}
</button>
</div>
)}
</>
) : (
<div className="premium-glass p-16 rounded-[var(--radius-panel)] text-center max-w-2xl mx-auto border-dashed border-[var(--border-color)]">
<div className="text-4xl mb-6 opacity-40">🧘</div>
<p className="font-bold text-lg mb-2 text-[var(--text-primary)]">Silence radio...</p>
<p className="text-[var(--text-secondary)] text-sm leading-relaxed mb-8">
Vous n'avez pas encore d'épisodes ici. Abonnez-vous à vos podcasts favoris pour commencer votre collection.
</p>
<Link to="/trending" className="neon-button">Explorer les podcasts</Link>
</div>
</section>
)}
</main>
{/* Episode Slide-over Details */}
<EpisodeDetails
episode={detailsEpisode}
isOpen={isDetailsOpen}
onClose={() => setIsDetailsOpen(false)}
onPlay={playEpisode}
/>
{/* ── Récemment publiés ── */}
<section>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[var(--bg-surface)] flex items-center justify-center text-[var(--text-secondary)] border border-[var(--border-color)]">
<Clock className="w-5 h-5" />
</div>
<h3 className="text-xl lg:text-2xl">Récemment publiés</h3>
</div>
<span className="text-[0.6rem] font-bold text-[var(--text-muted)] uppercase tracking-[0.12em] px-3 py-1.5 rounded-lg bg-[var(--bg-surface)] border border-[var(--border-color)]">
{episodes.length} chargés
</span>
</div>
<AlertModal
isOpen={alertConfig.isOpen}
title={alertConfig.title}
message={alertConfig.message}
type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))}
/>
{episodesLoading && skip === 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[1,2,3,4].map(i => <div key={i} className="skeleton aspect-[3/4] rounded-[var(--radius-lg)]" />)}
</div>
) : episodes.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 stagger">
{episodes.map(ep => (
<EpisodeCard key={`${skip}-${ep._id}`} episode={ep} onPlay={playEpisode} onDetails={handleOpenDetails} onToggleRead={handleToggleRead} />
))}
</div>
{hasMore && (
<div className="flex justify-center mt-10 pb-20">
<button onClick={loadMore} disabled={episodesFetching}
className="btn-secondary min-w-[200px]">
{episodesFetching ? <Loader className="w-4 h-4 animate-spin" /> : <ChevronDown className="w-4 h-4" />}
{episodesFetching ? 'Chargement...' : 'Charger la suite'}
</button>
</div>
)}
</>
) : (
<div className="glass rounded-[var(--radius-xl)] p-16 text-center max-w-xl mx-auto border-dashed">
<div className="text-4xl mb-4 opacity-40">🎧</div>
<h3 className="text-lg mb-2">Silence radio...</h3>
<p className="text-[var(--text-secondary)] text-sm leading-relaxed mb-6">
Abonnez-vous à vos podcasts favoris pour voir leurs épisodes ici.
</p>
<Link to="/trending" className="btn-primary">Explorer les podcasts</Link>
</div>
)}
</section>
</div>
<EpisodeDetails episode={detailsEpisode} isOpen={isDetailsOpen} onClose={() => setIsDetailsOpen(false)} onPlay={playEpisode} />
<AlertModal isOpen={alertConfig.isOpen} title={alertConfig.title} message={alertConfig.message} type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
</div>
);
}
+53 -83
View File
@@ -1,44 +1,29 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { podcastService, Podcast } from '../services/podcastService';
import { authService } from '../services/authService';
import Header from '../components/Header';
import { BookOpen, Rss } from 'lucide-react';
import PodcastCard from '../components/PodcastCard';
import { Link } from 'react-router-dom';
import AlertModal, { AlertType } from '../components/AlertModal';
import { BookOpen, Plus, Loader } from 'lucide-react';
export default function Library() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [user, setUser] = useState<any>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
// Alert states
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean;
title: string;
message: string;
type: AlertType;
}>({
isOpen: false,
title: '',
message: '',
type: 'error'
});
isOpen: boolean; title: string; message: string; type: AlertType;
}>({ isOpen: false, title: '', message: '', type: 'error' });
useEffect(() => {
const loadUser = async () => {
try {
const response = await authService.getMe();
setUser(response.user);
} catch (error) {
console.error('Failed to load user:', error);
}
};
loadUser();
authService.getMe().then(r => setUser(r.user)).catch(() => {});
}, []);
const { data: subscriptionsData, isLoading } = useQuery({
const { data: subsData, isLoading } = useQuery({
queryKey: ['podcasts', 'subscriptions'],
queryFn: () => podcastService.getUserSubscriptions(),
staleTime: 2 * 60 * 1000,
@@ -48,29 +33,23 @@ export default function Library() {
mutationFn: (podcastId: string) => podcastService.unsubscribe(podcastId),
onMutate: async (podcastId: string) => {
await queryClient.cancelQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.setQueryData(
['podcasts', 'subscriptions'],
(old: any) => {
if (!old || !old.podcasts) return old;
const filtered = old.podcasts.filter((p: any) =>
String(p.subscriptionId || p._id) !== String(podcastId)
);
return { ...old, podcasts: filtered, count: filtered.length };
}
);
queryClient.setQueryData(['podcasts', 'subscriptions'], (old: any) => {
if (!old?.podcasts) return old;
const filtered = old.podcasts.filter((p: any) =>
String(p.subscriptionId || p._id) !== String(podcastId)
);
return { ...old, podcasts: filtered, count: filtered.length };
});
setConfirmDeleteId(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
onError: (error: any) => {
onError: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
setAlertConfig({
isOpen: true,
title: "Erreur de désabonnement",
message: "Une erreur est survenue lors de la tentative de désabonnement. Veuillez réessayer plus tard.",
type: 'error'
isOpen: true, title: "Erreur", message: "Impossible de se désabonner. Réessayez.", type: 'error'
});
},
});
@@ -83,73 +62,67 @@ export default function Library() {
},
});
const handleConfirmUnsubscribe = () => {
if (confirmDeleteId) {
unsubscribeMutation.mutate(confirmDeleteId);
}
};
const handleLogout = async () => {
try {
await authService.logout();
authService.clearTokens();
window.location.href = '/login';
} catch (error) {
console.error(error);
}
} catch { /* ignore */ }
};
const podcasts: Podcast[] = subscriptionsData?.podcasts ?? [];
const podcasts: Podcast[] = subsData?.podcasts ?? [];
return (
<div className="min-h-screen">
<Header
title="Ma Bibliothèque"
subtitle="VOTRE COLLECTION"
user={user}
onLogout={handleLogout}
/>
<Header title="Ma Bibliothèque" subtitle="Votre collection" user={user} onLogout={handleLogout} />
{/* Stats */}
{/* Stats row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-10">
<div className="premium-card premium-glass p-5 rounded-[var(--radius-card)] flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-glow)] flex items-center justify-center text-[var(--accent-primary)] shrink-0">
<div className="glass rounded-[var(--radius-lg)] p-5 flex items-center gap-4">
<div className="w-11 h-11 rounded-xl bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)] shrink-0">
<BookOpen className="w-5 h-5" />
</div>
<div>
<p className="text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest">Abonnements</p>
<h3 className="text-2xl font-display font-black">{podcasts.length}</h3>
<span className="text-[0.6rem] font-bold text-[var(--text-muted)] uppercase tracking-widest">Abonnements</span>
<p className="text-2xl font-display font-extrabold">{podcasts.length}</p>
</div>
</div>
<Link to="/add" className="premium-card premium-glass p-5 rounded-[var(--radius-card)] flex items-center gap-4 group hover:bg-[var(--accent-primary)]/5 transition-all">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-primary)] flex items-center justify-center text-white shadow-glow-indigo group-hover:scale-110 transition-transform shrink-0">
<Rss className="w-5 h-5" />
<Link to="/add"
className="glass rounded-[var(--radius-lg)] p-5 flex items-center gap-4 group hover:border-[var(--accent-primary)]/30 transition-all cursor-pointer">
<div className="w-11 h-11 rounded-xl bg-[var(--accent-primary)] flex items-center justify-center text-white shrink-0 group-hover:scale-105 transition-transform shadow-lg shadow-[var(--accent-primary)]/20">
<Plus className="w-5 h-5" />
</div>
<div>
<p className="text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest">Action</p>
<h3 className="text-base font-bold">Ajouter un podcast</h3>
<span className="text-[0.6rem] font-bold text-[var(--text-muted)] uppercase tracking-widest">Action</span>
<p className="text-base font-bold">Ajouter un podcast</p>
</div>
</Link>
</div>
{/* Podcast grid */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-32">
<div className="w-16 h-16 rounded-full border-4 border-[var(--border-color)] border-t-[var(--accent-primary)] animate-spin mb-6" />
<p className="text-[var(--text-secondary)] font-medium animate-pulse text-[10px] uppercase tracking-widest">Chargement de votre univers...</p>
<div className="flex flex-col items-center justify-center py-32 gap-4">
<Loader className="w-10 h-10 text-[var(--accent-primary)] animate-spin-slow" />
<span className="text-[0.65rem] font-bold text-[var(--text-muted)] uppercase tracking-[0.2em] animate-pulse">
Chargement de votre univers...
</span>
</div>
) : podcasts.length === 0 ? (
<div className="premium-glass py-24 px-12 rounded-[var(--radius-panel)] text-center max-w-2xl mx-auto">
<div className="w-24 h-24 rounded-[var(--radius-card)] bg-[var(--bg-secondary)] flex items-center justify-center text-4xl mx-auto mb-8 border border-[var(--border-color)]">🕳️</div>
<h2 className="text-3xl font-display font-black mb-4">Bibliothèque vide</h2>
<p className="text-[var(--text-secondary)] mb-10 leading-relaxed">Commencez votre voyage audio en ajoutant vos podcasts préférés dès maintenant.</p>
<Link to="/add" className="neon-button inline-flex items-center gap-3">
<Rss className="w-5 h-5" />
Découvrir des podcasts
<div className="glass rounded-[var(--radius-xl)] py-20 px-8 text-center max-w-lg mx-auto border-dashed">
<div className="w-20 h-20 rounded-2xl bg-[var(--bg-surface)] flex items-center justify-center text-3xl mx-auto mb-6 border border-[var(--border-color)]">
🎙️
</div>
<h2 className="text-2xl mb-3">Bibliothèque vide</h2>
<p className="text-[var(--text-secondary)] mb-8 leading-relaxed text-sm">
Commencez votre voyage audio en ajoutant vos podcasts préférés.
</p>
<Link to="/add" className="btn-primary">
<Plus className="w-4 h-4" /> Découvrir des podcasts
</Link>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 pb-10">
{podcasts.map((podcast) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-10 stagger">
{podcasts.map(podcast => (
<PodcastCard
key={podcast._id}
title={podcast.title}
@@ -157,26 +130,23 @@ export default function Library() {
imageUrl={podcast.imageUrl}
episodeCount={podcast.episodeCount}
language={podcast.language}
description={(podcast as any).description}
lastEpisodeTitle={podcast.lastEpisodeTitle}
lastEpisodeDate={podcast.lastEpisodeDate}
onSync={() => syncMutation.mutate(podcast._id)}
isSyncing={syncMutation.isPending && syncMutation.variables === podcast._id}
onUnsubscribe={() => setConfirmDeleteId(podcast.subscriptionId || podcast._id)}
confirmDelete={confirmDeleteId === (podcast.subscriptionId || podcast._id)}
onConfirmDelete={handleConfirmUnsubscribe}
onConfirmDelete={() => unsubscribeMutation.mutate(confirmDeleteId!)}
onCancelDelete={() => setConfirmDeleteId(null)}
onClick={() => navigate(`/podcast/${podcast._id}`)}
/>
))}
</div>
)}
<AlertModal
isOpen={alertConfig.isOpen}
title={alertConfig.title}
message={alertConfig.message}
type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))}
/>
<AlertModal isOpen={alertConfig.isOpen} title={alertConfig.title} message={alertConfig.message} type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
</div>
);
}
+1 -1
View File
@@ -1,7 +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';
import { Sparkles, ArrowRight, Mail, Lock, User as UserIcon } from 'lucide-react';
export default function Login() {
const navigate = useNavigate();
+207
View File
@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { podcastService, Podcast } from '../services/podcastService';
import { episodeService, Episode } from '../services/episodeService';
import { authService } from '../services/authService';
import Header from '../components/Header';
import EpisodeCard from '../components/EpisodeCard';
import EpisodeDetails from '../components/EpisodeDetails';
import { useAudio } from '../context/AudioContext';
import AlertModal, { AlertType } from '../components/AlertModal';
import { Check, Loader, RefreshCw, Trash2, ArrowLeft, Rss } from 'lucide-react';
export default function PodcastDetails() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { playEpisode } = useAudio();
const [user, setUser] = useState<any>(null);
const [detailsEpisode, setDetailsEpisode] = useState<Episode | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean; title: string; message: string; type: AlertType;
}>({ isOpen: false, title: '', message: '', type: 'error' });
useEffect(() => {
authService.getMe().then(r => setUser(r.user)).catch(() => {});
}, []);
const { data: podcastData, isLoading: podcastLoading } = useQuery({
queryKey: ['podcast', id],
queryFn: () => podcastService.getPodcast(id!),
enabled: !!id,
});
const { data: episodesData, isLoading: episodesLoading } = useQuery({
queryKey: ['episodes', 'podcast', id],
queryFn: () => episodeService.getPodcastEpisodes(id!, 50, 0),
enabled: !!id,
});
const unsubscribeMutation = useMutation({
mutationFn: () => podcastService.unsubscribe(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
navigate('/library');
},
onError: () => {
setAlertConfig({
isOpen: true, title: "Erreur", message: "Impossible de se désabonner.", type: 'error'
});
},
});
const syncMutation = useMutation({
mutationFn: () => podcastService.syncPodcast(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['episodes', 'podcast', id] });
queryClient.invalidateQueries({ queryKey: ['podcast', id] });
},
});
const podcast: Podcast | null = podcastData?.podcast ?? null;
const episodes: Episode[] = episodesData?.episodes ?? [];
if (podcastLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-10 h-10 rounded-full border-[3px] border-[var(--border-color)] border-t-[var(--accent-primary)] animate-spin" />
</div>
);
}
if (!podcast) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4">
<p className="text-lg font-bold text-[var(--text-secondary)]">Podcast introuvable</p>
<button onClick={() => navigate('/library')} className="btn-secondary">
<ArrowLeft className="w-4 h-4" /> Retour à la bibliothèque
</button>
</div>
);
}
return (
<div className="min-h-screen">
<Header title={podcast.title} subtitle="Détails du podcast" user={user} />
<div>
{/* Back button */}
<button onClick={() => navigate(-1)} className="btn-ghost mb-6">
<ArrowLeft className="w-4 h-4" /> Retour
</button>
{/* Podcast info card */}
<div className="glass rounded-[var(--radius-xl)] overflow-hidden mb-12">
<div className="flex flex-col md:flex-row gap-8 p-8">
{/* Artwork */}
<div className="shrink-0">
<div className="w-48 h-48 md:w-56 md:h-56 rounded-2xl overflow-hidden shadow-2xl border border-[var(--border-color)]">
{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-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-5xl">🎙️</div>
)}
</div>
</div>
{/* Info */}
<div className="flex-1 min-w-0 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-accent">
<Check className="w-3 h-3" /> Abonné
</span>
<span className="badge">{podcast.episodeCount} épisodes</span>
{podcast.language && <span className="badge">{podcast.language}</span>}
{podcast.category?.map(cat => (
<span key={cat} className="badge">{cat}</span>
))}
</div>
<h2 className="text-2xl lg:text-3xl">{podcast.author}</h2>
{podcast.description && (
<p className="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-4">{podcast.description}</p>
)}
<div className="flex flex-wrap items-center gap-3 mt-auto pt-4">
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="btn-secondary">
{syncMutation.isPending
? <><Loader className="w-4 h-4 animate-spin" /> Synchro...</>
: <><RefreshCw className="w-4 h-4" /> Synchroniser</>}
</button>
{confirmDelete ? (
<div className="flex items-center gap-2">
<button onClick={() => unsubscribeMutation.mutate()}
className="btn-primary !bg-[var(--accent-rose)] !from-[var(--accent-rose)] !to-[var(--accent-rose)] !shadow-[var(--accent-rose)]/20">
Confirmer
</button>
<button onClick={() => setConfirmDelete(false)} className="btn-secondary">
Annuler
</button>
</div>
) : (
<button onClick={() => setConfirmDelete(true)} className="btn-ghost !text-[var(--accent-rose)]">
<Trash2 className="w-4 h-4" /> Se désabonner
</button>
)}
</div>
</div>
</div>
{/* RSS URL */}
<div className="px-8 pb-6">
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
<Rss className="w-3.5 h-3.5" />
<span className="truncate">{podcast.rssUrl}</span>
</div>
</div>
</div>
{/* Episodes */}
<section>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl lg:text-2xl">Épisodes</h3>
<span className="text-[0.6rem] font-bold text-[var(--text-muted)] uppercase tracking-widest">
{episodes.length} épisodes
</span>
</div>
{episodesLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[1,2,3,4].map(i => <div key={i} className="skeleton aspect-[3/4] rounded-[var(--radius-lg)]" />)}
</div>
) : episodes.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-20 stagger">
{episodes.map(ep => (
<EpisodeCard
key={ep._id}
episode={ep}
onPlay={playEpisode}
onDetails={(e) => { setDetailsEpisode(e); setIsDetailsOpen(true); }}
/>
))}
</div>
) : (
<div className="glass rounded-[var(--radius-xl)] p-12 text-center max-w-lg mx-auto">
<p className="text-sm font-bold text-[var(--text-secondary)] uppercase tracking-wider">Aucun épisode trouvé</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Synchronisez ce podcast pour charger ses épisodes.</p>
</div>
)}
</section>
</div>
<EpisodeDetails episode={detailsEpisode} isOpen={isDetailsOpen} onClose={() => setIsDetailsOpen(false)} onPlay={playEpisode} />
<AlertModal isOpen={alertConfig.isOpen} title={alertConfig.title} message={alertConfig.message} type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
</div>
);
}
+73 -144
View File
@@ -3,48 +3,31 @@ 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, Flame, Sparkles, TrendingUp } from 'lucide-react';
import PodcastCard from '../components/PodcastCard';
import { authService } from '../services/authService';
import SuccessModal from '../components/SuccessModal';
import AlertModal, { AlertType } from '../components/AlertModal';
import { GENRES } from '../constants';
import { Flame, Loader, ChevronDown, Sparkles } from 'lucide-react';
export default function Trending() {
const queryClient = useQueryClient();
const [user, setUser] = useState<any>(null);
const [subscribingId, setSubscribingId] = useState<string | null>(null);
const [selectedGenre, setSelectedGenre] = useState<string>('');
// Modal state
const [selectedGenre, setSelectedGenre] = useState('');
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successPodcast, setSuccessPodcast] = useState<any>(null);
const [podcasts, setPodcasts] = useState<any[]>([]);
const [skip, setSkip] = useState(0);
const [hasMore, setHasMore] = useState(true);
const LIMIT = 30;
// Alert state
const [alertConfig, setAlertConfig] = useState<{
isOpen: boolean;
title: string;
message: string;
type: AlertType;
}>({
isOpen: false,
title: '',
message: '',
type: 'error'
});
isOpen: boolean; title: string; message: string; type: AlertType;
}>({ isOpen: false, title: '', message: '', type: 'error' });
useEffect(() => {
const loadUser = async () => {
try {
const response = await authService.getMe();
setUser(response.user);
} catch (error) {
console.error('Failed to load user:', error);
}
};
loadUser();
authService.getMe().then(r => setUser(r.user)).catch(() => {});
}, []);
const { data: subsData } = useQuery({
@@ -52,11 +35,6 @@ export default function Trending() {
queryFn: () => podcastService.getUserSubscriptions(),
});
const [skip, setSkip] = useState(0);
const [podcasts, setPodcasts] = useState<any[]>([]);
const [hasMore, setHasMore] = useState(true);
const LIMIT = 30;
const { data: trendingData, isLoading, isFetching } = useQuery({
queryKey: ['discover', 'trending', selectedGenre, skip],
queryFn: () => discoveryService.getTrendingPodcasts(LIMIT, selectedGenre),
@@ -69,50 +47,39 @@ export default function Trending() {
setPodcasts(trendingData.podcasts);
} else {
setPodcasts(prev => {
const newItems = trendingData.podcasts.filter(
const fresh = trendingData.podcasts.filter(
(p: any) => !prev.some(old => old.id === p.id)
);
return [...prev, ...newItems];
return [...prev, ...fresh];
});
}
setHasMore(trendingData.podcasts.length === LIMIT);
}
}, [trendingData, skip]);
// Reset skip when genre changes
useEffect(() => {
setSkip(0);
}, [selectedGenre]);
useEffect(() => { setSkip(0); }, [selectedGenre]);
const loadMore = () => {
if (hasMore && !isFetching) {
setSkip(prev => prev + LIMIT);
}
if (hasMore && !isFetching) setSkip(prev => prev + LIMIT);
};
const isSubscribed = (rssUrl: string) => {
return subsData?.podcasts.some(p => p.rssUrl === rssUrl);
};
const isSubscribed = (rssUrl: string) =>
subsData?.podcasts.some((p: any) => p.rssUrl === rssUrl);
const subscribeMutation = useMutation({
mutationFn: (podcast: DiscoveryPodcast) =>
discoveryService.subscribeFromDiscovery(podcast.rssUrl, {
title: podcast.title,
author: podcast.author,
imageUrl: podcast.imageUrl,
title: podcast.title, author: podcast.author, imageUrl: podcast.imageUrl,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
onError: (error: any) => {
onError: () => {
setAlertConfig({
isOpen: true,
title: "Échec de l'abonnement",
message: error.response?.data?.message || "Une erreur est survenue. Le flux de ce podcast est peut-être temporairement inaccessible.",
type: 'error'
isOpen: true, title: "Erreur", message: "Impossible de s'abonner. Le flux est peut-être inaccessible.", type: 'error'
});
}
},
});
const handleSubscribe = async (podcast: DiscoveryPodcast) => {
@@ -122,16 +89,8 @@ export default function Trending() {
setSuccessPodcast(podcast);
setShowSuccessModal(true);
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
} catch (error: any) {
setAlertConfig({
isOpen: true,
title: "Action impossible",
message: "Une erreur critique est survenue lors de la tentative d'abonnement.",
type: 'error'
});
} finally {
setSubscribingId(null);
}
} catch { /* handled in onError */ }
finally { setSubscribingId(null); }
};
const handleLogout = async () => {
@@ -139,118 +98,88 @@ export default function Trending() {
await authService.logout();
authService.clearTokens();
window.location.href = '/login';
} catch (error) {
console.error('Logout failed:', error);
}
} catch { /* ignore */ }
};
return (
<div className="min-h-screen">
<Header
title="Tendances"
subtitle="EXPLORATION"
user={user}
onLogout={handleLogout}
/>
<Header title="Tendances" subtitle="Exploration" user={user} onLogout={handleLogout} />
<main className="">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10">
<div>
{/* Section header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8">
<div className="flex items-center gap-3">
<div className="w-8 h-8 md:w-10 md:h-10 rounded-xl bg-accent-rose/10 flex items-center justify-center text-accent-rose shrink-0">
<Flame className="w-5 h-5 md:w-6 md:h-6 animate-pulse" />
<div className="w-10 h-10 rounded-xl bg-[var(--accent-rose)]/10 flex items-center justify-center text-[var(--accent-rose)] shrink-0">
<Flame className="w-5 h-5" />
</div>
<div className="min-w-0">
<h2 className="text-xl md:text-2xl font-display font-black truncate">Top Podcasts</h2>
<p className="text-[9px] md:text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest">Mis à jour en temps réel</p>
<div>
<h2 className="text-xl lg:text-2xl">Top Podcasts</h2>
<span className="text-[0.6rem] font-bold text-[var(--text-muted)] uppercase tracking-widest">Mis à jour en temps réel</span>
</div>
</div>
{/* Genre Pills with Scroll Indicators */}
<div className="relative flex-1 min-w-0 group/genres mb-14">
{/* Fade effect left */}
<div className="absolute left-0 top-0 bottom-4 w-12 bg-gradient-to-r from-[var(--bg-primary)] to-transparent z-10 pointer-events-none opacity-100 sm:hidden" />
<div className="flex items-center gap-2 overflow-x-auto pb-4 no-scrollbar scroll-smooth">
<div className="flex items-center gap-2 flex-nowrap px-4 sm:px-0">
{GENRES.map(genre => (
<button
key={genre.id}
onClick={() => setSelectedGenre(genre.id)}
className={`px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-widest whitespace-nowrap transition-all border ${
selectedGenre === genre.id
? 'bg-[var(--accent-primary)] text-white border-[var(--accent-primary)] shadow-glow-indigo'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-[var(--accent-primary)]'
}`}
>
{genre.label}
</button>
))}
</div>
</div>
{/* Fade effect right */}
<div className="absolute right-0 top-0 bottom-4 w-12 bg-gradient-to-l from-[var(--bg-primary)] to-transparent z-10 pointer-events-none opacity-100 sm:hidden" />
</div>
</div>
{/* Genre pills */}
<div className="relative mb-10">
<div className="flex items-center gap-2 overflow-x-auto pb-3 no-scrollbar">
{GENRES.map(genre => (
<button key={genre.id} onClick={() => setSelectedGenre(genre.id)}
className={`shrink-0 px-4 py-2.5 rounded-lg text-[0.65rem] font-bold uppercase tracking-wider whitespace-nowrap transition-all border ${
selectedGenre === genre.id
? 'bg-[var(--accent-primary)] text-white border-[var(--accent-primary)] shadow-lg shadow-[var(--accent-primary)]/20'
: 'bg-[var(--bg-surface)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-[var(--border-hover)] hover:text-[var(--text-primary)]'
}`}>
{genre.label}
</button>
))}
</div>
{/* Fade edges on mobile */}
<div className="absolute left-0 top-0 bottom-3 w-10 bg-gradient-to-r from-[var(--bg-base)] to-transparent pointer-events-none sm:hidden" />
<div className="absolute right-0 top-0 bottom-3 w-10 bg-gradient-to-l from-[var(--bg-base)] to-transparent pointer-events-none sm:hidden" />
</div>
{/* Results */}
{isLoading && skip === 0 ? (
<div className="flex flex-col items-center justify-center py-32">
<div className="w-12 h-12 rounded-full border-4 border-[var(--border-color)] border-t-[var(--accent-primary)] animate-spin mb-4" />
<p className="text-[var(--text-secondary)] font-bold uppercase tracking-widest text-[10px]">Analyse des tendances...</p>
<div className="flex flex-col items-center justify-center py-32 gap-4">
<Loader className="w-10 h-10 text-[var(--accent-primary)] animate-spin-slow" />
<span className="text-[0.65rem] font-bold text-[var(--text-muted)] uppercase tracking-widest">Analyse des tendances...</span>
</div>
) : podcasts.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 mb-24 stagger-grid">
{podcasts.map((podcast, index) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 stagger">
{podcasts.map((podcast, i) => (
<PodcastCard
key={`${skip}-${podcast.id}`}
title={podcast.title}
author={podcast.author}
imageUrl={podcast.imageUrl}
rank={index + 1}
title={podcast.title} author={podcast.author} imageUrl={podcast.imageUrl}
rank={i + 1}
onSubscribe={() => handleSubscribe(podcast)}
isSubscribed={isSubscribed(podcast.rssUrl)}
isSubscribing={subscribingId === podcast.id}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center pb-32">
<button
onClick={loadMore}
disabled={isFetching}
className="px-10 py-4 rounded-2xl bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[10px] font-black uppercase tracking-[0.2em] hover:bg-[var(--accent-primary)]/10 hover:border-[var(--accent-primary)] transition-all flex items-center gap-3"
>
{isFetching ? <Loader className="w-4 h-4 animate-spin" /> : <TrendingUp className="w-4 h-4" />}
{isFetching ? 'Chargement...' : 'Charger la suite'}
</button>
<div className="flex justify-center mt-10 pb-32">
<button onClick={loadMore} disabled={isFetching} className="btn-secondary min-w-[200px]">
{isFetching ? <Loader className="w-4 h-4 animate-spin" /> : <ChevronDown className="w-4 h-4" />}
{isFetching ? 'Chargement...' : 'Charger la suite'}
</button>
</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>
<div className="glass rounded-[var(--radius-xl)] p-16 text-center max-w-lg mx-auto">
<Sparkles className="w-10 h-10 text-[var(--text-muted)] mx-auto mb-4" />
<p className="text-sm font-bold text-[var(--text-secondary)] uppercase tracking-wider">Aucune tendance pour le moment</p>
</div>
)}
</main>
</div>
<SuccessModal
isOpen={showSuccessModal}
onClose={() => setShowSuccessModal(false)}
podcastTitle={successPodcast?.title || ""}
podcastAuthor={successPodcast?.author || ""}
podcastImage={successPodcast?.imageUrl || ""}
/>
<AlertModal
isOpen={alertConfig.isOpen}
title={alertConfig.title}
message={alertConfig.message}
type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))}
/>
<SuccessModal isOpen={showSuccessModal} onClose={() => setShowSuccessModal(false)}
podcastTitle={successPodcast?.title || ""} podcastAuthor={successPodcast?.author || ""}
podcastImage={successPodcast?.imageUrl || ""} />
<AlertModal isOpen={alertConfig.isOpen} title={alertConfig.title} message={alertConfig.message} type={alertConfig.type}
onClose={() => setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
</div>
);
}
+10
View File
@@ -75,4 +75,14 @@ export const episodeService = {
const response = await api.get(`/episodes/progress/${episodeId}`);
return response.data;
},
searchEpisodes: async (
query: string,
limit: number = 40
): Promise<EpisodeResponse> => {
const response = await api.get('/episodes/search', {
params: { q: query, limit },
});
return response.data;
},
};
+350 -227
View File
@@ -2,176 +2,301 @@
@tailwind components;
@tailwind utilities;
/* ═══════════════════════════════════════════
DESIGN TOKENS
═══════════════════════════════════════════ */
@layer base {
/* ──────────────────────────────────────────────────
DARK THEME — Default
────────────────────────────────────────────────── */
:root,
:root.dark-theme,
.dark-theme {
--bg-primary: #080d1a;
--bg-secondary: rgba(255, 255, 255, 0.025);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent-primary: #6366f1;
:root {
/* Dark theme (default) */
--bg-base: #020617;
--bg-surface: #0f1222;
--bg-elevated: #161a2e;
--bg-glass: rgba(15, 18, 34, 0.7);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-primary: #6366f1;
--accent-secondary: #06b6d4;
--accent-gold: #d4a843;
--accent-glow: rgba(99, 102, 241, 0.35);
--border-color: rgba(255, 255, 255, 0.07);
--glass-blur: blur(24px) saturate(180%);
--radius-card: 1rem; /* 16px — sharp modern */
--radius-panel: 1.25rem; /* 20px */
--shadow-premium: 0 8px 40px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.05);
--shadow-hover: 0 16px 60px rgba(0, 0, 0, 0.65), inset 0 1px 0 rgba(255, 255, 255, 0.08);
--input-bg: rgba(255, 255, 255, 0.03);
--accent-rose: #f43f5e;
--accent-gold: #f59e0b;
--accent-emerald: #10b981;
--accent-glow: rgba(99, 102, 241, 0.25);
--border-color: rgba(255, 255, 255, 0.06);
--border-hover: rgba(255, 255, 255, 0.12);
--radius-sm: 0.625rem;
--radius-md: 0.875rem;
--radius-lg: 1.125rem;
--radius-xl: 1.375rem;
--glass-blur: blur(24px) saturate(180%);
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.03);
--shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06);
--shadow-glow: 0 0 24px var(--accent-glow);
--font-display: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
--font-body: 'Outfit', ui-sans-serif, system-ui, sans-serif;
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ──────────────────────────────────────────────────
LIGHT THEME — Cream / Grey / Gold
────────────────────────────────────────────────── */
:root.light-theme,
.light-theme {
--bg-primary: #f7f3eb; /* warm off-white cream */
--bg-secondary: rgba(255, 255, 255, 0.85);
--text-primary: #1e293b; /* dark slate navy */
--text-secondary: #64748b; /* medium grey */
--accent-primary: #5b5fc7; /* slightly deeper indigo on white */
--bg-base: #f8f5f0;
--bg-surface: #f0ece6;
--bg-elevated: #e8e3da;
--bg-glass: rgba(255, 252, 247, 0.8);
--text-primary: #1a1a2e;
--text-secondary: #5c6370;
--text-muted: #9099a8;
--accent-primary: #5b5fc7;
--accent-secondary: #0891b2;
--accent-gold: #b8960c; /* rich gold */
--accent-glow: rgba(91, 95, 199, 0.15);
--border-color: rgba(184, 150, 12, 0.16); /* warm golden border */
--glass-blur: blur(20px) saturate(150%);
--radius-card: 1rem;
--radius-panel: 1.25rem;
--shadow-premium: 0 4px 24px rgba(0, 0, 0, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.95);
--shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 1);
--input-bg: rgba(255, 255, 255, 0.82);
--accent-rose: #e11d48;
--accent-gold: #b8960c;
--accent-emerald: #059669;
--accent-glow: rgba(91, 95, 199, 0.15);
--border-color: rgba(0, 0, 0, 0.06);
--border-hover: rgba(0, 0, 0, 0.12);
--glass-blur: blur(20px) saturate(150%);
--shadow-card: 0 1px 8px rgba(0, 0, 0, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.8);
--shadow-card-hover: 0 6px 24px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.9);
--shadow-glow: 0 0 20px var(--accent-glow);
}
}
/* ── Premium card hover ── */
.premium-card {
@apply transition-all duration-300;
box-shadow: var(--shadow-premium);
}
.premium-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-hover);
/* ═══════════════════════════════════════════
RESET & BASE
═══════════════════════════════════════════ */
*,
*::before,
*::after { box-sizing: border-box; }
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
/* ── Reset ── */
* { @apply box-border; }
html { @apply scroll-smooth; }
/* ── Body ── */
body {
background-color: var(--bg-primary);
margin: 0;
padding: 0;
background-color: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-body);
font-weight: 400;
line-height: 1.6;
min-height: 100vh;
transition: background-color 0.5s cubic-bezier(0.2, 0.8, 0.2, 1),
color 0.3s ease;
@apply antialiased font-sans overflow-x-hidden;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color var(--transition-slow), color var(--transition-base);
}
/* Dark background gradient (fixed pseudo-element) */
body.dark-theme::before {
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(ellipse 80% 40% at 10% 10%, rgba(99, 102, 241, 0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 90% 90%, rgba(6, 182, 212, 0.06) 0%, transparent 60%);
background:
radial-gradient(ellipse 80% 35% at 10% 5%, rgba(99, 102, 241, 0.08) 0%, transparent 55%),
radial-gradient(ellipse 60% 40% at 90% 95%, rgba(6, 182, 212, 0.06) 0%, transparent 55%),
radial-gradient(ellipse 50% 30% at 50% 50%, rgba(99, 102, 241, 0.03) 0%, transparent 50%);
}
/* Light background gradient — cream with gold top-left, grey bottom-right */
body.light-theme::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(ellipse 70% 45% at 5% 5%, rgba(184, 150, 12, 0.12) 0%, transparent 55%),
radial-gradient(ellipse 55% 45% at 96% 96%, rgba(140, 152, 180, 0.10) 0%, transparent 55%),
radial-gradient(ellipse 50% 30% at 50% 100%, rgba(200, 190, 170, 0.06) 0%, transparent 50%);
background:
radial-gradient(ellipse 70% 40% at 5% 5%, rgba(184, 150, 12, 0.10) 0%, transparent 55%),
radial-gradient(ellipse 55% 40% at 96% 96%, rgba(140, 152, 180, 0.08) 0%, transparent 55%),
radial-gradient(ellipse 50% 25% at 50% 100%, rgba(200, 190, 170, 0.05) 0%, transparent 50%);
}
/* ── Scrollbar ── */
#root {
min-height: 100vh;
isolation: isolate;
}
/* ═══════════════════════════════════════════
TYPOGRAPHY
═══════════════════════════════════════════ */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.15;
color: var(--text-primary);
}
p { margin: 0; }
/* ═══════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════ */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-primary); opacity: 0.4; }
::-webkit-scrollbar-thumb {
background: var(--border-hover);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover { background: var(--accent-primary); }
/* ── Typography ── */
h1, h2, h3, h4 {
@apply font-display font-extrabold tracking-tight;
color: var(--text-primary);
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* ═══════════════════════════════════════════
COMPONENT CLASSES
═══════════════════════════════════════════ */
@layer components {
/* Glass surface */
.glass {
background: var(--bg-glass);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-card);
transition: background var(--transition-slow), border-color var(--transition-base), box-shadow var(--transition-base);
}
/* Elevated card */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-card-hover);
border-color: var(--border-hover);
}
/* Input field */
.input {
width: 100%;
padding: 0.875rem 1.125rem;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 0.9375rem;
outline: none;
transition: border-color var(--transition-base), box-shadow var(--transition-base), background var(--transition-base);
}
.input::placeholder { color: var(--text-muted); }
.input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
background: var(--bg-elevated);
}
/* Primary button */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: #fff;
font-family: var(--font-body);
font-weight: 700;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
box-shadow: var(--shadow-glow);
transition: transform var(--transition-base), filter var(--transition-base), box-shadow var(--transition-base);
user-select: none;
}
.btn-primary:hover {
transform: translateY(-1px);
filter: brightness(1.1);
box-shadow: 0 0 32px var(--accent-glow);
}
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled {
opacity: 0.5;
transform: none;
cursor: not-allowed;
filter: none;
}
/* Secondary button */
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--bg-surface);
color: var(--text-secondary);
font-family: var(--font-body);
font-weight: 700;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
user-select: none;
}
.btn-secondary:hover {
color: var(--text-primary);
border-color: var(--border-hover);
background: var(--bg-elevated);
}
/* Ghost button */
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: transparent;
color: var(--text-secondary);
font-family: var(--font-body);
font-weight: 600;
font-size: 0.75rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-base);
user-select: none;
}
.btn-ghost:hover { color: var(--text-primary); background: var(--bg-surface); }
/* Badge */
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 99px;
color: var(--text-secondary);
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.badge-accent {
background: var(--accent-glow);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
}
/* ── Glass card ── */
.premium-glass {
background: var(--bg-secondary);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-premium);
transition:
background 0.4s ease,
border-color 0.4s ease,
box-shadow 0.4s ease;
}
/* ── Inputs ── */
.input-premium {
@apply w-full px-5 py-4 rounded-xl transition-all outline-none;
background: var(--input-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.input-premium::placeholder { color: var(--text-secondary); opacity: 0.7; }
.input-premium:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
background: var(--bg-secondary);
}
/* ── Neon button ── */
.neon-button {
@apply relative inline-flex items-center justify-center gap-2 px-6 py-3 rounded-xl
font-bold text-white transition-all duration-300 active:scale-[0.97] select-none;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
box-shadow: 0 4px 20px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.neon-button:hover {
transform: translateY(-1px);
filter: brightness(1.1);
box-shadow: 0 8px 28px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.neon-button:disabled {
opacity: 0.5;
transform: none;
cursor: not-allowed;
}
/* ── Light theme: inverse player background ── */
.dark-theme {
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
}
/* Used as inverse wrapper inside AudioPlayer when in light mode */
.player-inverse {
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--border-color: rgba(255, 255, 255, 0.12);
--bg-secondary: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
/* ═══════════════════════════════════════════════════
/* ═══════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════ */
═══════════════════════════════════════════ */
@keyframes fade-in {
from { opacity: 0; }
@@ -179,43 +304,38 @@ h1, h2, h3, h4 {
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-14px); }
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes reveal {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
@keyframes slide-left {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.93); }
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
100% { background-position: 200% 0; }
}
@keyframes aura-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.75; transform: scale(1.1); }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes glow-beat {
0%, 100% { box-shadow: 0 0 16px var(--accent-glow); }
50% { box-shadow: 0 0 36px var(--accent-glow); }
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 12px var(--accent-glow); }
50% { box-shadow: 0 0 28px var(--accent-glow); }
}
@keyframes spin-slow {
@@ -223,87 +343,90 @@ h1, h2, h3, h4 {
to { transform: rotate(360deg); }
}
@keyframes ping-slow {
0% { transform: scale(1); opacity: 1; }
75%, 100% { transform: scale(2); opacity: 0; }
}
@keyframes gold-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* ── Animation utility classes ── */
.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-slide-down { animation: slide-down 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.animate-fade-in { animation: fade-in 0.3s ease both; }
/* Animation utility classes */
.animate-fade-in { animation: fade-in 0.3s ease both; }
.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.animate-slide-down { animation: slide-down 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.animate-slide-left { animation: slide-left 0.3s ease both; }
.animate-scale-in { animation: scale-in 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.animate-float { animation: float 3.5s ease-in-out infinite; }
.animate-aura { animation: aura-pulse 10s ease-in-out infinite; }
.animate-glow-beat { animation: glow-beat 3s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 10s linear infinite; }
.animate-spin-paused { animation-play-state: paused; }
.animate-ping-slow { animation: ping-slow 2.5s cubic-bezier(0, 0, 0.2, 1) infinite; }
.animate-float { animation: float 4s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 12s linear infinite; }
@keyframes eq-bar {
0%, 100% { transform: scaleY(0.25); }
50% { transform: scaleY(1); }
}
.animate-eq-bar {
animation: eq-bar 0.9s ease-in-out infinite;
transform-origin: bottom;
}
/* ── Shimmer loading skeleton ── */
.shimmer {
/* Shimmer skeleton */
.skeleton {
background: linear-gradient(
90deg,
var(--bg-secondary) 25%,
rgba(255, 255, 255, 0.08) 50%,
var(--bg-secondary) 75%
var(--bg-surface) 25%,
var(--bg-elevated) 50%,
var(--bg-surface) 75%
);
background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite;
border-radius: var(--radius-md);
}
/* ── Gold shimmer accent (light theme decorative) ── */
.gold-shimmer {
background: linear-gradient(
90deg,
transparent,
rgba(184, 150, 12, 0.15),
transparent
);
background-size: 200% 100%;
animation: gold-shimmer 3s ease-in-out infinite;
}
/* ═══════════════════════════════════════════════════
STAGGER GRID
Add class "stagger-grid" to a grid container —
children animate in with a cascade delay.
═══════════════════════════════════════════════════ */
.stagger-grid > * {
/* Stagger grid */
.stagger > * {
animation: slide-up 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.stagger-grid > *:nth-child(1) { animation-delay: 0ms; }
.stagger-grid > *:nth-child(2) { animation-delay: 65ms; }
.stagger-grid > *:nth-child(3) { animation-delay: 130ms; }
.stagger-grid > *:nth-child(4) { animation-delay: 195ms; }
.stagger-grid > *:nth-child(5) { animation-delay: 260ms; }
.stagger-grid > *:nth-child(6) { animation-delay: 325ms; }
.stagger-grid > *:nth-child(7) { animation-delay: 390ms; }
.stagger-grid > *:nth-child(8) { animation-delay: 455ms; }
.stagger-grid > *:nth-child(9) { animation-delay: 520ms; }
.stagger-grid > *:nth-child(10) { animation-delay: 585ms; }
.stagger-grid > *:nth-child(11) { animation-delay: 650ms; }
.stagger-grid > *:nth-child(12) { animation-delay: 715ms; }
.stagger-grid > *:nth-child(n+13) { animation-delay: 780ms; }
.stagger > *:nth-child(1) { animation-delay: 0ms; }
.stagger > *:nth-child(2) { animation-delay: 55ms; }
.stagger > *:nth-child(3) { animation-delay: 110ms; }
.stagger > *:nth-child(4) { animation-delay: 165ms; }
.stagger > *:nth-child(5) { animation-delay: 220ms; }
.stagger > *:nth-child(6) { animation-delay: 275ms; }
.stagger > *:nth-child(7) { animation-delay: 330ms; }
.stagger > *:nth-child(8) { animation-delay: 385ms; }
.stagger > *:nth-child(9) { animation-delay: 440ms; }
.stagger > *:nth-child(10) { animation-delay: 495ms; }
.stagger > *:nth-child(11) { animation-delay: 550ms; }
.stagger > *:nth-child(12) { animation-delay: 605ms; }
.stagger > *:nth-child(n+13) { animation-delay: 660ms; }
/* ── Utilities ── */
.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 var(--accent-glow); }
.shadow-glow-cyan { box-shadow: 0 0 20px rgba(6, 182, 212, 0.35); }
.shadow-glow-gold { box-shadow: 0 0 20px rgba(184, 150, 12, 0.4); }
/* ═══════════════════════════════════════════
AUDIO PLAYER SPECIFIC
═══════════════════════════════════════════ */
.player-artwork-ring {
box-shadow:
0 0 0 4px var(--bg-base),
0 0 0 5px var(--border-color),
0 8px 32px rgba(0, 0, 0, 0.5);
}
/* Range input styling */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 4px;
background: var(--border-color);
border-radius: 99px;
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-primary);
box-shadow: 0 0 8px var(--accent-glow);
cursor: pointer;
transition: transform var(--transition-base);
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* ═══════════════════════════════════════════
UTILITIES
═══════════════════════════════════════════ */
.text-balance { text-wrap: balance; }
.truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26 -35
View File
@@ -7,50 +7,41 @@ export default {
theme: {
extend: {
colors: {
obsidian: {
DEFAULT: '#020617',
900: '#020617',
800: '#0f172a',
700: '#1e293b',
brand: {
indigo: '#6366f1',
cyan: '#06b6d4',
rose: '#f43f5e',
gold: '#f59e0b',
emerald: '#10b981',
violet: '#8b5cf6',
},
accent: {
indigo: '#6366f1',
cyan: '#06b6d4',
rose: '#f43f5e',
violet: '#8b5cf6',
gold: '#b8960c',
surface: {
base: 'var(--bg-base)',
surface: 'var(--bg-surface)',
elevated:'var(--bg-elevated)',
glass: 'var(--bg-glass)',
},
light: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
}
},
fontFamily: {
sans: ['"Outfit"', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
sans: ['"Outfit"', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
display: ['"Montserrat"', 'sans-serif'],
},
borderRadius: {
'xl': '0.75rem', /* 12px */
'2xl': '1rem', /* 16px — matches --radius-card */
'3xl': '1.25rem', /* 20px — matches --radius-panel */
'4xl': '1.5rem', /* 24px */
'sm': 'var(--radius-sm)',
'md': 'var(--radius-md)',
'lg': 'var(--radius-lg)',
'xl': 'var(--radius-xl)',
},
boxShadow: {
'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)',
animation: {
'fade-in': 'fade-in 0.3s ease both',
'slide-up': 'slide-up 0.4s cubic-bezier(0.2,0.8,0.2,1) both',
'slide-down': 'slide-down 0.35s cubic-bezier(0.2,0.8,0.2,1) both',
'slide-left': 'slide-left 0.3s ease both',
'scale-in': 'scale-in 0.35s cubic-bezier(0.2,0.8,0.2,1) both',
'float': 'float 4s ease-in-out infinite',
'spin-slow': 'spin-slow 12s linear infinite',
'shimmer': 'shimmer 1.6s ease-in-out infinite',
},
backdropBlur: {
'xs': '2px',
}
},
},
plugins: [],