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