feat: implement podcast discovery and management controllers and services

This commit is contained in:
Michael committed 2026-04-16 14:57:49 +02:00
1 parent 7923f4cf09
commit a993e2d658
11 files changed
+318 -183

No files matched your search

+2 -1
View File
@@ -291,7 +291,8 @@ export const subscribeFromDiscovery = async (req: Request, res: Response) => {
export const getTrendingPodcasts = async (req: Request, res: Response) => {
try {
const limit = Number(req.query.limit) || 20;
const trending = await podcastIndexService.getTrendingPodcasts(limit);
const genre = req.query.genre as string;
const trending = await podcastIndexService.getTrendingPodcasts(limit, genre);
res.json({ source: 'itunes', podcasts: trending, count: trending.length });
} catch (error: any) {
console.error('Trending podcasts error:', error.message);
+5 -3
View File
@@ -77,9 +77,11 @@ export const podcastIndexService = {
.filter((r): r is SearchResult => r !== null);
},
getTrendingPodcasts: async (limit: number = 20): Promise<SearchResult[]> => {
// iTunes official top podcasts RSS (France)
getTrendingPodcasts: async (limit: number = 20, genreId?: string): Promise<SearchResult[]> => {
// iTunes official top podcasts RSS
// Format: https://itunes.apple.com/fr/rss/toppodcasts/limit=X/genre=Y/json
const cap = Math.min(limit, 50);
const genrePath = genreId ? `/genre=${genreId}` : '';
const rssResponse = await axios.get<{
feed: {
entry: Array<{
@@ -89,7 +91,7 @@ export const podcastIndexService = {
'im:image': Array<{ label: string; attributes: { height: string } }>;
}>;
};
}>(`https://itunes.apple.com/fr/rss/toppodcasts/limit=${cap}/json`, {
}>(`https://itunes.apple.com/fr/rss/toppodcasts/limit=${cap}${genrePath}/json`, {
timeout: 10000,
});
+33 -33
View File
@@ -122,7 +122,7 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
isExpanded
? 'inset-0 rounded-none'
: 'bottom-6 left-1/2 -translate-x-1/2 w-[96%] max-w-5xl rounded-[2rem] h-[88px]'
} premium-glass border-white/10 shadow-2xl overflow-hidden flex flex-col`}>
} premium-glass border-[var(--border-color)] shadow-2xl overflow-hidden flex flex-col`}>
<audio
ref={audioRef}
@@ -139,63 +139,63 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
{/* Artwork + info */}
<div className="flex items-center gap-3 min-w-0 flex-1 max-w-[280px]">
<div className="w-13 h-13 w-12 h-12 rounded-xl overflow-hidden border border-white/10 shrink-0 shadow-lg">
<div className="w-12 h-12 rounded-xl overflow-hidden border border-[var(--border-color)] shrink-0 shadow-lg">
{(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-accent-indigo to-accent-violet flex items-center justify-center text-xl">🎙️</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-xl">🎙️</div>
}
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-white truncate leading-tight">{episode.title}</p>
<p className="text-[10px] text-slate-500 font-bold truncate uppercase tracking-wider">{podcast?.title || ''}</p>
<p className="text-sm font-bold truncate leading-tight">{episode.title}</p>
<p className="text-[10px] text-[var(--text-secondary)] font-bold truncate uppercase tracking-wider">{podcast?.title || ''}</p>
</div>
</div>
{/* Controls + progress */}
<div className="flex-1 flex flex-col items-center gap-1.5">
<div className="flex items-center gap-5">
<button onClick={() => handleSeek(-30)} className="group flex flex-col items-center gap-0.5 text-slate-500 hover:text-white transition-colors">
<button onClick={() => handleSeek(-30)} className="group flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors">
<RotateCcw className="w-4 h-4" />
<span className="text-[8px] font-black">30</span>
</button>
<button onClick={handlePlayPause}
className="w-11 h-11 rounded-full bg-white text-obsidian flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
className="w-11 h-11 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">
{isPlaying ? <Pause className="w-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current ml-0.5" />}
</button>
<button onClick={() => handleSeek(30)} className="group flex flex-col items-center gap-0.5 text-slate-500 hover:text-white transition-colors">
<button onClick={() => handleSeek(30)} className="group flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors">
<RotateCw className="w-4 h-4" />
<span className="text-[8px] font-black">30</span>
</button>
</div>
<div className="w-full max-w-lg flex items-center gap-2">
<span className="text-[9px] text-slate-600 tabular-nums w-8 text-right">{formatTime(currentTime)}</span>
<span className="text-[9px] text-[var(--text-secondary)] tabular-nums w-8 text-right">{formatTime(currentTime)}</span>
<div className="relative flex-1 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-white/10 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-accent-indigo to-accent-cyan rounded-full transition-all duration-100"
<div 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>
<span className="text-[9px] text-slate-600 tabular-nums w-8">{formatTime(duration)}</span>
<span className="text-[9px] text-[var(--text-secondary)] tabular-nums w-8">{formatTime(duration)}</span>
</div>
</div>
{/* Right actions */}
<div className="flex items-center gap-2 shrink-0">
<button onClick={changeSpeed} className="px-2 py-1 rounded-lg bg-white/5 text-[10px] font-black text-slate-500 hover:text-white transition-all uppercase tracking-tighter w-10">
<button onClick={changeSpeed} className="px-2 py-1 rounded-lg bg-[var(--bg-secondary)] text-[10px] font-black text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-tighter w-10">
{playbackSpeed}x
</button>
<button onClick={toggleMute} className="p-2 text-slate-500 hover:text-white transition-colors">
<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>
<button onClick={() => setIsExpanded(true)}
className="p-2 rounded-xl bg-white/5 text-slate-500 hover:text-white hover:bg-white/10 transition-all">
className="p-2 rounded-xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10 transition-all">
<Maximize2 className="w-4 h-4" />
</button>
<button onClick={onClose}
className="p-2 rounded-xl bg-white/5 text-slate-500 hover:text-accent-rose hover:bg-accent-rose/10 transition-all">
className="p-2 rounded-xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-accent-rose hover:bg-accent-rose/10 transition-all">
<X className="w-4 h-4" />
</button>
</div>
@@ -207,31 +207,31 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
{/* Top bar */}
<div className="flex items-center justify-between mb-10">
<button onClick={() => setIsExpanded(false)}
className="p-3 rounded-2xl bg-white/5 text-slate-400 hover:text-white transition-all">
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all">
<ChevronDown className="w-5 h-5" />
</button>
<p className="text-[10px] font-black text-accent-indigo uppercase tracking-[0.3em]">En cours de lecture</p>
<p className="text-[10px] font-black text-[var(--accent-primary)] uppercase tracking-[0.3em]">En cours de lecture</p>
<button onClick={onClose}
className="p-3 rounded-2xl bg-white/5 text-slate-400 hover:text-accent-rose transition-all">
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-accent-rose 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-white/10 shrink-0">
<div className="w-64 h-64 lg:w-80 lg:h-80 rounded-[2.5rem] overflow-hidden shadow-2xl border border-[var(--border-color)] shrink-0">
{(episode.imageUrl || podcast?.imageUrl)
? <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-accent-indigo to-accent-violet 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-accent-indigo uppercase tracking-widest mb-2">{podcast?.title}</p>
<h2 className="text-3xl lg:text-4xl font-display font-black text-white leading-tight mb-3">{episode.title}</h2>
<p className="text-sm text-slate-500 leading-relaxed line-clamp-3">{episode.description}</p>
<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>
</div>
{/* Progress */}
@@ -240,12 +240,12 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
<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-white/10 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-accent-indigo to-accent-cyan rounded-full"
<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"
style={{ width: `${progress}%` }} />
</div>
</div>
<div className="flex justify-between text-xs text-slate-500 tabular-nums">
<div className="flex justify-between text-xs text-[var(--text-secondary)] tabular-nums">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
@@ -254,18 +254,18 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
{/* Controls */}
<div className="flex items-center justify-center gap-8">
<button onClick={() => handleSeek(-30)}
className="flex flex-col items-center gap-1 text-slate-400 hover:text-white transition-colors group">
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCcw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">-30s</span>
</button>
<button onClick={handlePlayPause}
className="w-20 h-20 rounded-full bg-white text-obsidian flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
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">
{isPlaying ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
</button>
<button onClick={() => handleSeek(30)}
className="flex flex-col items-center gap-1 text-slate-400 hover:text-white transition-colors group">
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">+30s</span>
</button>
@@ -273,15 +273,15 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro
{/* Volume */}
<div className="flex items-center gap-4">
<button onClick={changeSpeed} className="px-3 py-1.5 rounded-xl bg-white/5 text-xs font-black text-slate-400 hover:text-white transition-all uppercase tracking-widest">
<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">
{playbackSpeed}x Speed
</button>
<button onClick={toggleMute} className="text-slate-400 hover:text-white transition-colors shrink-0">
<button onClick={toggleMute} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors shrink-0">
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button>
<input type="range" min="0" max="1" step="0.05" value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="flex-1 h-1 bg-white/10 rounded-full appearance-none cursor-pointer accent-white" />
className="flex-1 h-1 bg-[var(--text-primary)]/10 rounded-full appearance-none cursor-pointer accent-[var(--accent-primary)]" />
</div>
</div>
</div>
+47 -33
View File
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import { LogOut, User as UserIcon } from 'lucide-react';
import { LogOut, User as UserIcon, Sun, Moon } from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
interface HeaderProps {
title: string;
@@ -9,50 +10,63 @@ interface HeaderProps {
}
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-accent-indigo/10 border border-accent-indigo/20 text-[10px] font-bold text-accent-indigo uppercase tracking-widest mb-3">
<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-4xl lg:text-5xl font-display font-extrabold text-white tracking-tight text-balance leading-tight">
<h1 className="text-4xl lg:text-5xl font-display font-extrabold tracking-tight text-balance leading-tight">
{title}
</h1>
</div>
{/* User Profile */}
{user && (
<div className="flex items-center gap-6">
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm font-bold text-white tracking-wide">{user.username}</span>
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-widest">Premium Member</span>
</div>
<div className="relative group">
<div className="w-12 h-12 rounded-2xl p-[1px] bg-gradient-to-tr from-accent-indigo/50 to-accent-rose/50 group-hover:from-accent-indigo group-hover:to-accent-rose transition-all">
<div className="w-full h-full rounded-2xl bg-obsidian-800 flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt={user.username} className="w-full h-full object-cover" />
) : (
<span className="text-lg font-bold text-accent-indigo">{user.username.charAt(0).toUpperCase()}</span>
)}
{/* User Profile + Theme Toggle */}
<div className="flex items-center gap-4 sm:gap-6">
{/* Theme Toggle */}
<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"
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</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">Premium Member</span>
</div>
<div className="relative group/profile">
<div className="w-12 h-12 rounded-2xl p-[1px] bg-gradient-to-tr from-[var(--accent-primary)]/50 to-[var(--accent-secondary)]/50 group-hover/profile:from-[var(--accent-primary)] group-hover/profile:to-[var(--accent-secondary)] transition-all">
<div className="w-full h-full rounded-2xl bg-[var(--bg-primary)] flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt={user.username} className="w-full h-full object-cover" />
) : (
<span className="text-lg font-bold text-[var(--accent-primary)]">{user.username.charAt(0).toUpperCase()}</span>
)}
</div>
</div>
</div>
{/* Profile Popup (Minimal) */}
{onLogout && (
<button
onClick={onLogout}
className="absolute top-full right-0 mt-4 opacity-0 scale-90 translate-y-2 group-hover:opacity-100 group-hover:scale-100 group-hover:translate-y-0 transition-all duration-300 premium-glass px-4 py-2 flex items-center gap-2 hover:bg-white/10"
>
<LogOut className="w-4 h-4 text-accent-rose" />
<span className="text-xs font-bold text-white whitespace-nowrap">Logout</span>
</button>
)}
</div>
</div>
)}
{/* Profile Popup (Minimal) */}
{onLogout && (
<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"
>
<LogOut className="w-4 h-4 text-accent-rose" />
<span className="text-xs font-bold whitespace-nowrap">Logout</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
</header>
);
+48
View File
@@ -0,0 +1,48 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('podcastic-theme');
return (saved as Theme) || 'dark';
});
useEffect(() => {
localStorage.setItem('podcastic-theme', theme);
const root = window.document.documentElement;
// Remove both just in case
root.classList.remove('light-theme', 'dark-theme');
// Add current one
root.classList.add(`${theme}-theme`);
// Also apply to body for background compatibility
document.body.className = `${theme}-theme`;
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
+4 -1
View File
@@ -1,9 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { ThemeProvider } from './context/ThemeContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>,
)
+10 -10
View File
@@ -69,16 +69,16 @@ export default function Dashboard() {
<main className="">
{/* Welcome Section / Hero */}
<div className="premium-glass rounded-[3rem] p-8 lg:p-12 mb-12 relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-64 bg-accent-indigo/10 blur-[80px] -mr-32 -mt-32 group-hover:bg-accent-indigo/20 transition-all duration-700" />
<div className="absolute top-0 right-0 w-64 h-64 bg-[var(--accent-glow)] blur-[80px] -mr-32 -mt-32 group-hover:bg-[var(--accent-primary)]/20 transition-all duration-700" />
<div className="relative z-10">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-accent-indigo/10 border border-accent-indigo/20 text-[10px] font-black text-accent-indigo uppercase tracking-widest mb-4">
<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 Premium
</div>
<h2 className="text-4xl lg:text-5xl font-display font-black text-white mb-4 tracking-tight leading-tight max-w-2xl">
<h2 className="text-4xl lg:text-5xl font-display font-black mb-4 tracking-tight leading-tight max-w-2xl">
Découvrez les nouveautés de votre univers audio.
</h2>
<p className="text-slate-400 text-lg mb-8 max-w-xl leading-relaxed">
<p className="text-[var(--text-secondary)] text-lg mb-8 max-w-xl leading-relaxed">
Vous avez {episodesData?.count || 0} nouveaux épisodes qui n'attendent que vous. Prêt pour une immersion ?
</p>
<div className="flex flex-wrap gap-4">
@@ -96,7 +96,7 @@ export default function Dashboard() {
Découvrir
</button>
)}
<button className="px-6 py-3 rounded-2xl bg-white/5 text-slate-300 font-bold text-sm hover:bg-white/10 transition-all border border-white/5">
<button 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)]">
Parcourir les tendances
</button>
</div>
@@ -107,10 +107,10 @@ export default function Dashboard() {
{episodesInProgress.length > 0 && (
<div className="mb-12 animate-fade-in">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-accent-indigo/10 flex items-center justify-center text-accent-indigo shadow-glow-indigo">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-glow)] flex items-center justify-center text-[var(--accent-primary)] shadow-glow-indigo">
<Play className="w-5 h-5 fill-current" />
</div>
<h3 className="text-2xl font-display font-black text-white">Continuer l'écoute</h3>
<h3 className="text-2xl font-display font-black">Continuer l'écoute</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{episodesInProgress.map((episode) => (
@@ -127,12 +127,12 @@ export default function Dashboard() {
{/* Latest Episodes Section */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-cyan/10 flex items-center justify-center text-accent-cyan">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-glow)] flex items-center justify-center text-[var(--accent-primary)]">
<Clock className="w-5 h-5" />
</div>
<h3 className="text-2xl font-display font-black text-white">Récemment Publiés</h3>
<h3 className="text-2xl font-display font-black">Récemment Publiés</h3>
</div>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-3 py-1 rounded-lg border border-white/5">{episodesData?.episodes?.length || 0} EPISODES</span>
<span className="text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest px-3 py-1 rounded-lg border border-[var(--border-color)]">{episodesData?.episodes?.length || 0} EPISODES</span>
</div>
{episodesLoading ? (
+27 -27
View File
@@ -93,51 +93,51 @@ export default function Library() {
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-10">
<div className="premium-glass p-5 rounded-[1.5rem] 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 shrink-0">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-glow)] 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-slate-500 uppercase tracking-widest">Abonnements</p>
<h3 className="text-2xl font-display font-black text-white">{podcasts.length}</h3>
<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>
</div>
</div>
<Link to="/add" className="premium-glass p-5 rounded-[1.5rem] flex items-center gap-4 group hover:bg-accent-indigo/5 transition-all">
<div className="w-10 h-10 rounded-xl bg-accent-indigo flex items-center justify-center text-white shadow-glow-indigo group-hover:scale-110 transition-transform shrink-0">
<Link to="/add" className="premium-glass p-5 rounded-[1.5rem] 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" />
</div>
<div>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Action</p>
<h3 className="text-base font-bold text-white">Ajouter un podcast</h3>
<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>
</div>
</Link>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center py-32">
<div className="w-16 h-16 rounded-full border-4 border-accent-indigo/20 border-t-accent-indigo animate-spin mb-6" />
<p className="text-slate-400 font-medium animate-pulse">Chargement de votre univers...</p>
<div 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>
) : podcasts.length === 0 ? (
<div className="premium-glass py-24 px-12 rounded-[3.5rem] text-center max-w-2xl mx-auto">
<div className="w-24 h-24 rounded-[2rem] bg-white/[0.03] flex items-center justify-center text-4xl mx-auto mb-8 border border-white/5">🕳️</div>
<h2 className="text-3xl font-display font-black text-white mb-4">Bibliothèque vide</h2>
<p className="text-slate-400 mb-10 leading-relaxed">Commencez votre voyage audio en ajoutant vos podcasts préférés dès maintenant.</p>
<div className="w-24 h-24 rounded-[2rem] 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
</Link>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8 pb-10">
{podcasts.map((podcast) => (
<div key={podcast._id} className="group premium-glass rounded-[2.5rem] p-6 hover:bg-white/[0.05] transition-all duration-500 border-white/5 hover:border-white/10 flex flex-col h-full">
<div key={podcast._id} className="group premium-glass rounded-[2.5rem] p-6 hover:bg-[var(--bg-secondary)] transition-all duration-500 flex flex-col h-full border-[var(--border-color)]">
<div className="flex gap-6 mb-6">
<div className="relative shrink-0">
<div className="w-24 h-24 rounded-3xl overflow-hidden shadow-2xl border border-white/10 group-hover:scale-105 transition-transform duration-500">
<div className="w-24 h-24 rounded-3xl overflow-hidden shadow-2xl border border-[var(--border-color)] group-hover:scale-105 transition-transform duration-500">
{podcast.imageUrl ? (
<img src={podcast.imageUrl} alt={podcast.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-accent-indigo to-accent-violet flex items-center justify-center text-3xl">🎙️</div>
<div 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>
)}
</div>
<div className="absolute -bottom-2 -right-2 w-8 h-8 rounded-xl bg-white text-obsidian flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all">
@@ -147,18 +147,18 @@ export default function Library() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-bold text-white truncate leading-tight group-hover:text-accent-indigo transition-colors">
<h3 className="text-lg font-bold truncate leading-tight group-hover:text-[var(--accent-primary)] transition-colors">
{podcast.title}
</h3>
</div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-3">{podcast.author || 'Artiste Inconnu'}</p>
<p className="text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest mb-3">{podcast.author || 'Artiste Inconnu'}</p>
<div className="flex flex-wrap gap-2">
<span className="px-2.5 py-1 rounded-lg bg-white/5 border border-white/5 text-[9px] font-black text-slate-400 uppercase tracking-tighter">
<span className="px-2.5 py-1 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[9px] font-black text-[var(--text-secondary)] uppercase tracking-tighter">
{podcast.episodeCount} EPISODES
</span>
{podcast.language && (
<span className="px-2.5 py-1 rounded-lg bg-white/5 border border-white/5 text-[9px] font-black text-slate-400 uppercase">
<span className="px-2.5 py-1 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[9px] font-black text-[var(--text-secondary)] uppercase">
{podcast.language}
</span>
)}
@@ -188,10 +188,10 @@ export default function Library() {
</button>
</div>
) : podcast.lastEpisodeTitle && (
<div className="bg-white/[0.02] border border-white/5 rounded-2xl p-4 mb-6 group-hover:bg-white/[0.04] transition-colors">
<p className="text-[10px] font-bold text-accent-indigo uppercase tracking-[0.2em] mb-2">Dernier épisode</p>
<p className="text-sm font-bold text-slate-200 line-clamp-1 mb-1">{podcast.lastEpisodeTitle}</p>
<p className="text-[10px] font-medium text-slate-500 italic">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-2xl p-4 mb-6 group-hover:bg-[var(--accent-primary)]/5 transition-colors">
<p className="text-[10px] font-bold text-[var(--accent-primary)] uppercase tracking-[0.2em] mb-2">Dernier épisode</p>
<p className="text-sm font-bold line-clamp-1 mb-1">{podcast.lastEpisodeTitle}</p>
<p className="text-[10px] font-medium text-[var(--text-secondary)] italic">
{new Date(podcast.lastEpisodeDate!).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
@@ -199,16 +199,16 @@ export default function Library() {
</div>
{/* Actions Footer */}
<div className="flex items-center justify-between pt-6 border-t border-white/5 mt-auto">
<div className="flex items-center justify-between pt-6 border-t border-[var(--border-color)] mt-auto">
{confirmDeleteId === (podcast.subscriptionId || podcast._id) ? (
<div className="flex items-center gap-3 w-full animate-fade-in">
<button onClick={handleConfirmUnsubscribe} className="flex-1 py-2 bg-accent-rose text-white rounded-xl text-[11px] font-black uppercase hover:bg-accent-rose/80">Confirmer</button>
<button onClick={() => setConfirmDeleteId(null)} className="flex-1 py-2 bg-white/5 text-slate-400 rounded-xl text-[11px] font-black uppercase hover:bg-white/10">Annuler</button>
<button onClick={() => setConfirmDeleteId(null)} className="flex-1 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-xl text-[11px] font-black uppercase hover:bg-[var(--bg-primary)]">Annuler</button>
</div>
) : (
<button
onClick={() => setConfirmDeleteId(podcast.subscriptionId || podcast._id)}
className="ml-auto flex items-center gap-2 text-[10px] font-bold text-slate-600 hover:text-accent-rose transition-all uppercase tracking-widest"
className="ml-auto flex items-center gap-2 text-[10px] font-bold text-[var(--text-secondary)] hover:text-accent-rose transition-all uppercase tracking-widest"
>
<Trash2 className="w-3 h-3" />
Désabonner
+51 -18
View File
@@ -11,11 +11,25 @@ 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 [showSuccessModal, setShowSuccessModal] = useState(false);
const [successPodcast, setSuccessPodcast] = useState<any>(null);
const GENRES = [
{ id: '', label: 'Tout' },
{ id: '1318', label: 'Technologie' },
{ id: '1303', label: 'Humour' },
{ id: '1321', label: 'Business' },
{ id: '1311', label: 'News' },
{ id: '1324', label: 'Société' },
{ id: '1316', label: 'Sports' },
{ id: '1310', label: 'Musique' },
{ id: '1301', label: 'Arts' },
{ id: '1315', label: 'Science' },
];
useEffect(() => {
const loadUser = async () => {
try {
@@ -29,8 +43,8 @@ export default function Trending() {
}, []);
const { data: trendingData, isLoading } = useQuery({
queryKey: ['discover', 'trending'],
queryFn: () => discoveryService.getTrendingPodcasts(30),
queryKey: ['discover', 'trending', selectedGenre],
queryFn: () => discoveryService.getTrendingPodcasts(30, selectedGenre),
staleTime: 30 * 60 * 1000,
});
@@ -89,25 +103,44 @@ export default function Trending() {
/>
<main className="">
<div className="flex items-center gap-3 mb-10">
<div className="w-10 h-10 rounded-xl bg-accent-rose/10 flex items-center justify-center text-accent-rose">
<Flame className="w-6 h-6 animate-pulse" />
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-rose/10 flex items-center justify-center text-accent-rose">
<Flame className="w-6 h-6 animate-pulse" />
</div>
<div>
<h2 className="text-2xl font-display font-black">Top Podcasts</h2>
<p className="text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-widest">Mis à jour en temps réel</p>
</div>
</div>
<div>
<h2 className="text-2xl font-display font-black text-white">Top Podcasts</h2>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Mis à jour en temps réel</p>
{/* Genre Pills */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0 no-scrollbar">
{GENRES.map(genre => (
<button
key={genre.id}
onClick={() => setSelectedGenre(genre.id)}
className={`px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest whitespace-nowrap transition-all border ${
selectedGenre === genre.id
? 'bg-[var(--accent-primary)] text-white border-[var(--accent-primary)] shadow-glow-indigo'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-[var(--accent-primary)]'
}`}
>
{genre.label}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center py-32">
<div className="w-12 h-12 rounded-full border-4 border-white/5 border-t-accent-rose animate-spin mb-4" />
<p className="text-slate-500 font-bold uppercase tracking-widest text-[10px]">Analyse des tendances...</p>
<div 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>
) : trendingData?.podcasts && trendingData.podcasts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{trendingData.podcasts.map((podcast, index) => (
<div key={podcast.id} className="group premium-glass rounded-[2.5rem] overflow-hidden flex flex-col hover:bg-white/[0.05] transition-all duration-500">
<div key={podcast.id} className="group premium-glass rounded-[2.5rem] overflow-hidden flex flex-col hover:bg-[var(--bg-secondary)] transition-all duration-500">
<div className="relative aspect-[16/10] overflow-hidden">
{podcast.imageUrl ? (
<img
@@ -116,22 +149,22 @@ export default function Trending() {
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-slate-800 to-obsidian flex items-center justify-center text-4xl">🎙️</div>
<div className="w-full h-full bg-gradient-to-br from-slate-200 to-slate-400 dark:from-slate-800 dark:to-obsididan flex items-center justify-center text-4xl">🎙️</div>
)}
<div className="absolute top-4 left-4 px-3 py-1 rounded-full bg-black/40 backdrop-blur-md border border-white/10 text-[10px] font-black text-white uppercase tracking-widest">
Rank #{index + 1}
</div>
<div className="absolute inset-0 bg-gradient-to-t from-obsidian via-transparent to-transparent opacity-60" />
<div className="absolute inset-0 bg-gradient-to-t from-[var(--bg-primary)] via-transparent to-transparent opacity-60" />
</div>
<div className="p-6 flex-1 flex flex-col">
<h3 className="text-lg font-bold text-white mb-2 line-clamp-2 leading-tight group-hover:text-accent-rose transition-colors">
<h3 className="text-lg font-bold mb-2 line-clamp-2 leading-tight group-hover:text-[var(--accent-primary)] transition-colors">
{podcast.title}
</h3>
{podcast.author && (
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4 truncate">{podcast.author}</p>
<p className="text-xs font-bold text-[var(--text-secondary)] uppercase tracking-widest mb-4 truncate">{podcast.author}</p>
)}
<p className="text-xs text-slate-400 line-clamp-3 mb-6 leading-relaxed flex-1">
<p className="text-xs text-[var(--text-secondary)] line-clamp-3 mb-6 leading-relaxed flex-1 opacity-80">
{podcast.description}
</p>
@@ -140,8 +173,8 @@ export default function Trending() {
disabled={subscribingId === podcast.id || subscribeMutation.isPending || isSubscribed(podcast.rssUrl)}
className={`w-full py-3.5 rounded-2xl font-bold uppercase tracking-widest text-[10px] transition-all duration-300 flex items-center justify-center gap-2 ${
isSubscribed(podcast.rssUrl)
? 'bg-accent-cyan/10 text-accent-cyan border border-accent-cyan/20 cursor-default'
: 'bg-white text-obsidian hover:bg-accent-rose hover:text-white shadow-lg active:scale-95'
? 'bg-[var(--accent-secondary)]/10 text-[var(--accent-secondary)] border border-[var(--accent-secondary)]/20 cursor-default'
: 'bg-[var(--text-primary)] text-[var(--bg-primary)] hover:bg-[var(--accent-primary)] hover:text-white shadow-lg active:scale-95'
}`}
>
{subscribingId === podcast.id ? (
+2 -2
View File
@@ -30,9 +30,9 @@ export const discoveryService = {
return response.data;
},
getTrendingPodcasts: async (limit: number = 20): Promise<DiscoveryResponse> => {
getTrendingPodcasts: async (limit: number = 20, genre?: string): Promise<DiscoveryResponse> => {
const response = await api.get('/podcasts/trending', {
params: { limit },
params: { limit, genre },
});
return response.data;
},
+89 -55
View File
@@ -2,12 +2,36 @@
@tailwind components;
@tailwind utilities;
:root {
--obsidian: #080d1a;
--accent-indigo: #6366f1;
--accent-cyan: #06b6d4;
--accent-rose: #f43f5e;
--accent-violet: #8b5cf6;
@layer base {
:root {
/* ── Midnight Aura (Default/Dark) ── */
--bg-primary: #080d1a;
--bg-secondary: rgba(255, 255, 255, 0.025);
--text-primary: #ffffff;
--text-secondary: #94a3b8;
--accent-primary: #6366f1; /* Indigo */
--accent-secondary: #06b6d4; /* Cyan */
--accent-glow: rgba(99, 102, 241, 0.35);
--border-color: rgba(255, 255, 255, 0.07);
--glass-blur: blur(24px) saturate(180%);
--shadow-premium: 0 8px 40px rgba(0, 0, 0, 0.4);
--input-bg: rgba(255, 255, 255, 0.03);
}
/* ── Premium Gold (Light Theme) ── */
:root.light-theme, .light-theme {
--bg-primary: #FDFCFB; /* Off-white */
--bg-secondary: rgba(212, 175, 55, 0.05); /* Very light gold tint */
--text-primary: #0a0a0a; /* True black */
--text-secondary: #4b5563; /* Slate 600 */
--accent-primary: #C5A028; /* Premium Gold */
--accent-secondary: #856404; /* Darker Gold */
--accent-glow: rgba(197, 160, 40, 0.2);
--border-color: rgba(197, 160, 40, 0.15);
--glass-blur: blur(20px) saturate(120%);
--shadow-premium: 0 12px 48px rgba(0, 0, 0, 0.08);
--input-bg: #ffffff;
}
}
* { @apply box-border; }
@@ -15,55 +39,81 @@
html { @apply scroll-smooth; }
body {
background-color: var(--obsidian);
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-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
@apply text-slate-200 antialiased font-sans overflow-x-hidden;
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;
}
/* Background Gradients */
body.dark-theme::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
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%);
pointer-events: none;
}
body.light-theme::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background-image:
radial-gradient(ellipse 80% 40% at 10% 10%, rgba(197, 160, 40, 0.05) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 90% 90%, rgba(255, 255, 255, 0.8) 0%, transparent 60%);
pointer-events: none;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); }
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
/* ── Typography ── */
h1,h2,h3,h4 { @apply font-display font-extrabold tracking-tight text-white; }
h1, h2, h3, h4 {
@apply font-display font-extrabold tracking-tight;
color: var(--text-primary);
}
/* ── Glass card ── */
.premium-glass {
background: rgba(255,255,255,0.025);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255,255,255,0.07);
box-shadow: 0 8px 40px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05);
background: var(--bg-secondary);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-premium), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* ── Inputs ── */
.input-premium {
@apply w-full px-5 py-4 rounded-2xl text-white
placeholder:text-slate-600 transition-all outline-none;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.07);
@apply w-full px-5 py-4 rounded-2xl transition-all outline-none;
background: var(--input-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.input-premium:focus {
background: rgba(255,255,255,0.05);
border-color: rgba(99,102,241,0.5);
box-shadow: 0 0 0 3px rgba(99,102,241,0.1);
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* ── Buttons ── */
.neon-button {
@apply relative inline-flex items-center justify-center gap-2 px-6 py-3 rounded-2xl
font-bold text-white transition-all duration-300 active:scale-[0.97] select-none;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
box-shadow: 0 4px 20px rgba(99,102,241,0.35), inset 0 1px 0 rgba(255,255,255,0.15);
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
box-shadow: var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.neon-button:hover {
box-shadow: 0 6px 28px rgba(99,102,241,0.5), inset 0 1px 0 rgba(255,255,255,0.2);
transform: translateY(-1px);
filter: brightness(1.1);
}
.neon-button:disabled {
opacity: 0.5;
@@ -72,33 +122,17 @@ h1,h2,h3,h4 { @apply font-display font-extrabold tracking-tight text-white; }
}
/* ── Animations ── */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes slide-up { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } }
@keyframes reveal { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
@keyframes aura-pulse { 0%, 100% { opacity: 0.5; transform: scale(1); } 50% { opacity: 0.8; transform: scale(1.08); } }
@keyframes slide-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes reveal {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes aura-pulse {
0%,100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.08); }
}
.reveal { animation: reveal 0.5s cubic-bezier(0.2,0.8,0.2,1) both; }
.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.2,0.8,0.2,1) both; }
.animate-fade-in { animation: fade-in 0.3s ease both; }
.animate-aura { animation: aura-pulse 10s ease-in-out infinite; }
.reveal { animation: reveal 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.animate-fade-in { animation: fade-in 0.3s ease both; }
.animate-aura { animation: aura-pulse 10s ease-in-out infinite; }
/* ── Utility ── */
.text-balance { text-wrap: balance; }
.shadow-glass { box-shadow: 0 8px 40px rgba(0,0,0,0.4); }
.shadow-glow-indigo { box-shadow: 0 0 20px rgba(99,102,241,0.35); }
.shadow-glow-cyan { box-shadow: 0 0 20px rgba(6,182,212,0.35); }
.shadow-glow-indigo { box-shadow: 0 0 20px var(--accent-glow); }
.shadow-glow-cyan { box-shadow: 0 0 20px var(--accent-glow); }