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
+1463 -1570

No files matched your search

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