)}
+ {/* Results */}
+ {isSearching || isSearchingEpisodes ? (
+
) : searchType === 'podcasts' && (searchQuery.length > 0 ? searchResults?.podcasts : trendingResults?.podcasts) ? (
-
- {(searchQuery.length > 0 ? searchResults?.podcasts : trendingResults?.podcasts).map((podcast: any) => (
+
+ {(searchQuery.length > 0 ? searchResults!.podcasts : trendingResults!.podcasts).map((podcast: any) => (
handleSubscribe(podcast)}
isSubscribed={isSubscribed(podcast.rssUrl)}
isSubscribing={subscribingId === podcast.id}
@@ -250,53 +195,27 @@ export default function AddPodcast() {
))}
) : searchType === 'episodes' && episodeResults?.episodes ? (
-
- {episodeResults.episodes.map((episode) => (
-
+
+ {episodeResults.episodes.map((ep: Episode) => (
+ { setDetailsEpisode(e); setIsDetailsOpen(true); }} />
))}
- ) : searchQuery.length >= 2 && !isSearching && !isSearchingEpisodes ? (
-
-
-
Aucun résultat sur iTunes FR
-
Vérifiez l'orthographe ou essayez un nom d'auteur.
+ ) : searchQuery.length >= 2 ? (
+
+
+
Aucun résultat
+
Vérifiez l'orthographe ou essayez un nom d'auteur.
- ) : (
-
- )}
-
+ ) : null}
+
-
setIsDetailsOpen(false)}
- onPlay={playEpisode}
- />
-
- setAlertConfig(prev => ({ ...prev, isOpen: false }))}
- />
-
- setShowSuccessModal(false)}
- podcastTitle={successPodcast?.title || ""}
- podcastAuthor={successPodcast?.author || ""}
- podcastImage={successPodcast?.imageUrl || ""}
- />
+ setIsDetailsOpen(false)} onPlay={playEpisode} />
+ setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
+ setShowSuccessModal(false)}
+ podcastTitle={successPodcast?.title || ""} podcastAuthor={successPodcast?.author || ""}
+ podcastImage={successPodcast?.imageUrl || ""} />
);
}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index e232be9..988e4a1 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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 (
);
}
@@ -149,154 +124,121 @@ export default function Dashboard() {
return (
-
- {/* Welcome Section / Hero — becomes audio player when playing */}
+
+ {/* ── Hero / Now Playing ── */}
{currentEpisode ? (
) : (
-
-
-
-
-
- Podcastic
-
-
- Découvrez les nouveautés de votre univers audio.
+
+
+
+
+
+ Podcastic
+
+
+ Découvrez les nouveautés de votre univers audio.
-
- Vous avez des nouveaux épisodes qui n'attendent que vous. Prêt pour une immersion ?
+
+ Vos nouveaux épisodes n'attendent que vous. Prêt pour l'immersion ?
-
- {episodesInProgress.length > 0 ? (
-
- ) : (
-
-
- Découvrir des podcasts
-
- )}
-
- Parcourir les tendances
-
+
+ {inProgress.length > 0 ? (
+
+ ) : (
+
+
+ Découvrir des podcasts
+
+ )}
+
+ Parcourir les tendances
+
-
-
- )}
-
- {/* Continue Listening Section (Only if progression exists) */}
- {episodesInProgress.length > 0 && (
-
-
-
- {episodesInProgress.slice(0, 4).map((episode) => (
-
- ))}
-
+
)}
- {/* Latest Episodes Section */}
-
-
-
-
+ {/* ── Continuer l'écoute ── */}
+ {inProgress.length > 0 && (
+
+
- {episodes.length} CHARGÉS
-
-
- {episodesLoading && skip === 0 ? (
-
- {[1, 2, 3, 4].map(i => (
-
- ))}
-
- ) : episodes.length > 0 ? (
- <>
-
- {episodes.map((episode) => (
-
+
Continuer l'écoute
+
+
+ {inProgress.slice(0, 4).map(ep => (
+
))}
-
- {hasMore && (
-
-
-
- )}
- >
- ) : (
-
-
🧘
-
Silence radio...
-
- Vous n'avez pas encore d'épisodes ici. Abonnez-vous à vos podcasts favoris pour commencer votre collection.
-
-
Explorer les podcasts
-
+
)}
-
- {/* Episode Slide-over Details */}
-
setIsDetailsOpen(false)}
- onPlay={playEpisode}
- />
+ {/* ── Récemment publiés ── */}
+
+
+
+
+
+
+
Récemment publiés
+
+
+ {episodes.length} chargés
+
+
- setAlertConfig(prev => ({ ...prev, isOpen: false }))}
- />
+ {episodesLoading && skip === 0 ? (
+
+ {[1,2,3,4].map(i =>
)}
+
+ ) : episodes.length > 0 ? (
+ <>
+
+ {episodes.map(ep => (
+
+ ))}
+
+ {hasMore && (
+
+
+
+ )}
+ >
+ ) : (
+
+
🎧
+
Silence radio...
+
+ Abonnez-vous à vos podcasts favoris pour voir leurs épisodes ici.
+
+
Explorer les podcasts
+
+ )}
+
+
+
+
setIsDetailsOpen(false)} onPlay={playEpisode} />
+
+ setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
);
}
diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx
index 34ac14f..d3cc1ee 100644
--- a/frontend/src/pages/Library.tsx
+++ b/frontend/src/pages/Library.tsx
@@ -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(null);
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
-
- // Alert states
+
const [alertConfig, setAlertConfig] = useState<{
- isOpen: boolean;
- title: string;
- message: string;
- type: AlertType;
- }>({
- isOpen: false,
- title: '',
- message: '',
- type: 'error'
- });
+ isOpen: boolean; title: string; message: string; type: AlertType;
+ }>({ isOpen: false, title: '', message: '', type: 'error' });
useEffect(() => {
- const loadUser = async () => {
- try {
- const response = await authService.getMe();
- setUser(response.user);
- } catch (error) {
- console.error('Failed to load user:', error);
- }
- };
- loadUser();
+ authService.getMe().then(r => setUser(r.user)).catch(() => {});
}, []);
- const { data: subscriptionsData, isLoading } = useQuery({
+ const { data: subsData, isLoading } = useQuery({
queryKey: ['podcasts', 'subscriptions'],
queryFn: () => podcastService.getUserSubscriptions(),
staleTime: 2 * 60 * 1000,
@@ -48,29 +33,23 @@ export default function Library() {
mutationFn: (podcastId: string) => podcastService.unsubscribe(podcastId),
onMutate: async (podcastId: string) => {
await queryClient.cancelQueries({ queryKey: ['podcasts', 'subscriptions'] });
- queryClient.setQueryData(
- ['podcasts', 'subscriptions'],
- (old: any) => {
- if (!old || !old.podcasts) return old;
- const filtered = old.podcasts.filter((p: any) =>
- String(p.subscriptionId || p._id) !== String(podcastId)
- );
- return { ...old, podcasts: filtered, count: filtered.length };
- }
- );
+ queryClient.setQueryData(['podcasts', 'subscriptions'], (old: any) => {
+ if (!old?.podcasts) return old;
+ const filtered = old.podcasts.filter((p: any) =>
+ String(p.subscriptionId || p._id) !== String(podcastId)
+ );
+ return { ...old, podcasts: filtered, count: filtered.length };
+ });
setConfirmDeleteId(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
- onError: (error: any) => {
+ onError: () => {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
setAlertConfig({
- isOpen: true,
- title: "Erreur de désabonnement",
- message: "Une erreur est survenue lors de la tentative de désabonnement. Veuillez réessayer plus tard.",
- type: 'error'
+ isOpen: true, title: "Erreur", message: "Impossible de se désabonner. Réessayez.", type: 'error'
});
},
});
@@ -83,73 +62,67 @@ export default function Library() {
},
});
- const handleConfirmUnsubscribe = () => {
- if (confirmDeleteId) {
- unsubscribeMutation.mutate(confirmDeleteId);
- }
- };
-
const handleLogout = async () => {
try {
await authService.logout();
authService.clearTokens();
window.location.href = '/login';
- } catch (error) {
- console.error(error);
- }
+ } catch { /* ignore */ }
};
- const podcasts: Podcast[] = subscriptionsData?.podcasts ?? [];
+ const podcasts: Podcast[] = subsData?.podcasts ?? [];
return (
-
+
- {/* Stats */}
+ {/* Stats row */}
-
-
+
+
-
Abonnements
-
{podcasts.length}
+
Abonnements
+
{podcasts.length}
-
-
-
+
+
-
Action
-
Ajouter un podcast
+
Action
+
Ajouter un podcast
+ {/* Podcast grid */}
{isLoading ? (
-
-
-
Chargement de votre univers...
+
+
+
+ Chargement de votre univers...
+
) : podcasts.length === 0 ? (
-
-
🕳️
-
Bibliothèque vide
-
Commencez votre voyage audio en ajoutant vos podcasts préférés dès maintenant.
-
-
- Découvrir des podcasts
+
+
+ 🎙️
+
+
Bibliothèque vide
+
+ Commencez votre voyage audio en ajoutant vos podcasts préférés.
+
+
+
Découvrir des podcasts
) : (
-
- {podcasts.map((podcast) => (
+
+ {podcasts.map(podcast => (
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}`)}
/>
))}
)}
-
setAlertConfig(prev => ({ ...prev, isOpen: false }))}
- />
+ setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
);
}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 8b764d2..db5d4aa 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -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();
diff --git a/frontend/src/pages/PodcastDetails.tsx b/frontend/src/pages/PodcastDetails.tsx
new file mode 100644
index 0000000..fec36c9
--- /dev/null
+++ b/frontend/src/pages/PodcastDetails.tsx
@@ -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
(null);
+ const [detailsEpisode, setDetailsEpisode] = useState(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 (
+
+ );
+ }
+
+ if (!podcast) {
+ return (
+
+
Podcast introuvable
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* Back button */}
+
+
+ {/* Podcast info card */}
+
+
+ {/* Artwork */}
+
+
+ {podcast.imageUrl ? (
+

+ ) : (
+
🎙️
+ )}
+
+
+
+ {/* Info */}
+
+
+
+ Abonné
+
+ {podcast.episodeCount} épisodes
+ {podcast.language && {podcast.language}}
+ {podcast.category?.map(cat => (
+ {cat}
+ ))}
+
+
+
{podcast.author}
+
+ {podcast.description && (
+
{podcast.description}
+ )}
+
+
+
+
+ {confirmDelete ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* RSS URL */}
+
+
+
+ {podcast.rssUrl}
+
+
+
+
+ {/* Episodes */}
+
+
+
Épisodes
+
+ {episodes.length} épisodes
+
+
+
+ {episodesLoading ? (
+
+ {[1,2,3,4].map(i =>
)}
+
+ ) : episodes.length > 0 ? (
+
+ {episodes.map(ep => (
+ { setDetailsEpisode(e); setIsDetailsOpen(true); }}
+ />
+ ))}
+
+ ) : (
+
+
Aucun épisode trouvé
+
Synchronisez ce podcast pour charger ses épisodes.
+
+ )}
+
+
+
+
setIsDetailsOpen(false)} onPlay={playEpisode} />
+ setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
+
+ );
+}
diff --git a/frontend/src/pages/Trending.tsx b/frontend/src/pages/Trending.tsx
index 2c89dd3..7877686 100644
--- a/frontend/src/pages/Trending.tsx
+++ b/frontend/src/pages/Trending.tsx
@@ -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(null);
const [subscribingId, setSubscribingId] = useState(null);
- const [selectedGenre, setSelectedGenre] = useState('');
-
- // Modal state
+ const [selectedGenre, setSelectedGenre] = useState('');
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successPodcast, setSuccessPodcast] = useState(null);
+ const [podcasts, setPodcasts] = useState([]);
+ 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([]);
- 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 (
-
+
-
-
+
+ {/* Section header */}
+
-
-
+
+
-
-
Top Podcasts
-
Mis à jour en temps réel
+
+
Top Podcasts
+ Mis à jour en temps réel
-
- {/* Genre Pills with Scroll Indicators */}
-
- {/* Fade effect left */}
-
-
-
-
- {GENRES.map(genre => (
-
- ))}
-
-
-
- {/* Fade effect right */}
-
-
+ {/* Genre pills */}
+
+
+ {GENRES.map(genre => (
+
+ ))}
+
+ {/* Fade edges on mobile */}
+
+
+
+
+ {/* Results */}
{isLoading && skip === 0 ? (
-
-
-
Analyse des tendances...
+
+
+ Analyse des tendances...
) : podcasts.length > 0 ? (
<>
-
- {podcasts.map((podcast, index) => (
+
+ {podcasts.map((podcast, i) => (
handleSubscribe(podcast)}
isSubscribed={isSubscribed(podcast.rssUrl)}
isSubscribing={subscribingId === podcast.id}
/>
))}
-
{hasMore && (
-
-
+
+
)}
>
) : (
-
-
-
Aucune tendance pour le moment
-
+
+
+
Aucune tendance pour le moment
+
)}
-
+
-
setShowSuccessModal(false)}
- podcastTitle={successPodcast?.title || ""}
- podcastAuthor={successPodcast?.author || ""}
- podcastImage={successPodcast?.imageUrl || ""}
- />
-
- setAlertConfig(prev => ({ ...prev, isOpen: false }))}
- />
+ setShowSuccessModal(false)}
+ podcastTitle={successPodcast?.title || ""} podcastAuthor={successPodcast?.author || ""}
+ podcastImage={successPodcast?.imageUrl || ""} />
+ setAlertConfig(prev => ({ ...prev, isOpen: false }))} />
);
}
diff --git a/frontend/src/services/episodeService.ts b/frontend/src/services/episodeService.ts
index b78a283..55fb3b7 100644
--- a/frontend/src/services/episodeService.ts
+++ b/frontend/src/services/episodeService.ts
@@ -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
=> {
+ const response = await api.get('/episodes/search', {
+ params: { q: query, limit },
+ });
+ return response.data;
+ },
};
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index f79816b..fe4d9b0 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -2,176 +2,301 @@
@tailwind components;
@tailwind utilities;
+/* ═══════════════════════════════════════════
+ DESIGN TOKENS
+ ═══════════════════════════════════════════ */
+
@layer base {
- /* ──────────────────────────────────────────────────
- DARK THEME — Default
- ────────────────────────────────────────────────── */
- :root,
- :root.dark-theme,
- .dark-theme {
- --bg-primary: #080d1a;
- --bg-secondary: rgba(255, 255, 255, 0.025);
- --text-primary: #f1f5f9;
- --text-secondary: #94a3b8;
- --accent-primary: #6366f1;
+ :root {
+ /* Dark theme (default) */
+ --bg-base: #020617;
+ --bg-surface: #0f1222;
+ --bg-elevated: #161a2e;
+ --bg-glass: rgba(15, 18, 34, 0.7);
+ --text-primary: #f1f5f9;
+ --text-secondary: #94a3b8;
+ --text-muted: #64748b;
+ --accent-primary: #6366f1;
--accent-secondary: #06b6d4;
- --accent-gold: #d4a843;
- --accent-glow: rgba(99, 102, 241, 0.35);
- --border-color: rgba(255, 255, 255, 0.07);
- --glass-blur: blur(24px) saturate(180%);
- --radius-card: 1rem; /* 16px — sharp modern */
- --radius-panel: 1.25rem; /* 20px */
- --shadow-premium: 0 8px 40px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.05);
- --shadow-hover: 0 16px 60px rgba(0, 0, 0, 0.65), inset 0 1px 0 rgba(255, 255, 255, 0.08);
- --input-bg: rgba(255, 255, 255, 0.03);
+ --accent-rose: #f43f5e;
+ --accent-gold: #f59e0b;
+ --accent-emerald: #10b981;
+ --accent-glow: rgba(99, 102, 241, 0.25);
+ --border-color: rgba(255, 255, 255, 0.06);
+ --border-hover: rgba(255, 255, 255, 0.12);
+ --radius-sm: 0.625rem;
+ --radius-md: 0.875rem;
+ --radius-lg: 1.125rem;
+ --radius-xl: 1.375rem;
+ --glass-blur: blur(24px) saturate(180%);
+ --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.03);
+ --shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ --shadow-glow: 0 0 24px var(--accent-glow);
+ --font-display: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
+ --font-body: 'Outfit', ui-sans-serif, system-ui, sans-serif;
+ --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
- /* ──────────────────────────────────────────────────
- LIGHT THEME — Cream / Grey / Gold
- ────────────────────────────────────────────────── */
:root.light-theme,
.light-theme {
- --bg-primary: #f7f3eb; /* warm off-white cream */
- --bg-secondary: rgba(255, 255, 255, 0.85);
- --text-primary: #1e293b; /* dark slate navy */
- --text-secondary: #64748b; /* medium grey */
- --accent-primary: #5b5fc7; /* slightly deeper indigo on white */
+ --bg-base: #f8f5f0;
+ --bg-surface: #f0ece6;
+ --bg-elevated: #e8e3da;
+ --bg-glass: rgba(255, 252, 247, 0.8);
+ --text-primary: #1a1a2e;
+ --text-secondary: #5c6370;
+ --text-muted: #9099a8;
+ --accent-primary: #5b5fc7;
--accent-secondary: #0891b2;
- --accent-gold: #b8960c; /* rich gold */
- --accent-glow: rgba(91, 95, 199, 0.15);
- --border-color: rgba(184, 150, 12, 0.16); /* warm golden border */
- --glass-blur: blur(20px) saturate(150%);
- --radius-card: 1rem;
- --radius-panel: 1.25rem;
- --shadow-premium: 0 4px 24px rgba(0, 0, 0, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.95);
- --shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 1);
- --input-bg: rgba(255, 255, 255, 0.82);
+ --accent-rose: #e11d48;
+ --accent-gold: #b8960c;
+ --accent-emerald: #059669;
+ --accent-glow: rgba(91, 95, 199, 0.15);
+ --border-color: rgba(0, 0, 0, 0.06);
+ --border-hover: rgba(0, 0, 0, 0.12);
+ --glass-blur: blur(20px) saturate(150%);
+ --shadow-card: 0 1px 8px rgba(0, 0, 0, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.8);
+ --shadow-card-hover: 0 6px 24px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.9);
+ --shadow-glow: 0 0 20px var(--accent-glow);
}
}
-/* ── Premium card hover ── */
-.premium-card {
- @apply transition-all duration-300;
- box-shadow: var(--shadow-premium);
-}
-.premium-card:hover {
- transform: translateY(-3px);
- box-shadow: var(--shadow-hover);
+/* ═══════════════════════════════════════════
+ RESET & BASE
+ ═══════════════════════════════════════════ */
+
+*,
+*::before,
+*::after { box-sizing: border-box; }
+
+html {
+ scroll-behavior: smooth;
+ -webkit-text-size-adjust: 100%;
}
-/* ── Reset ── */
-* { @apply box-border; }
-html { @apply scroll-smooth; }
-
-/* ── Body ── */
body {
- background-color: var(--bg-primary);
+ margin: 0;
+ padding: 0;
+ background-color: var(--bg-base);
color: var(--text-primary);
+ font-family: var(--font-body);
+ font-weight: 400;
+ line-height: 1.6;
min-height: 100vh;
- transition: background-color 0.5s cubic-bezier(0.2, 0.8, 0.2, 1),
- color 0.3s ease;
- @apply antialiased font-sans overflow-x-hidden;
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ transition: background-color var(--transition-slow), color var(--transition-base);
}
-/* Dark background gradient (fixed pseudo-element) */
-body.dark-theme::before {
+body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
- background-image:
- radial-gradient(ellipse 80% 40% at 10% 10%, rgba(99, 102, 241, 0.07) 0%, transparent 60%),
- radial-gradient(ellipse 60% 50% at 90% 90%, rgba(6, 182, 212, 0.06) 0%, transparent 60%);
+ background:
+ radial-gradient(ellipse 80% 35% at 10% 5%, rgba(99, 102, 241, 0.08) 0%, transparent 55%),
+ radial-gradient(ellipse 60% 40% at 90% 95%, rgba(6, 182, 212, 0.06) 0%, transparent 55%),
+ radial-gradient(ellipse 50% 30% at 50% 50%, rgba(99, 102, 241, 0.03) 0%, transparent 50%);
}
-/* Light background gradient — cream with gold top-left, grey bottom-right */
body.light-theme::before {
- content: "";
- position: fixed;
- inset: 0;
- z-index: -1;
- pointer-events: none;
- background-image:
- radial-gradient(ellipse 70% 45% at 5% 5%, rgba(184, 150, 12, 0.12) 0%, transparent 55%),
- radial-gradient(ellipse 55% 45% at 96% 96%, rgba(140, 152, 180, 0.10) 0%, transparent 55%),
- radial-gradient(ellipse 50% 30% at 50% 100%, rgba(200, 190, 170, 0.06) 0%, transparent 50%);
+ background:
+ radial-gradient(ellipse 70% 40% at 5% 5%, rgba(184, 150, 12, 0.10) 0%, transparent 55%),
+ radial-gradient(ellipse 55% 40% at 96% 96%, rgba(140, 152, 180, 0.08) 0%, transparent 55%),
+ radial-gradient(ellipse 50% 25% at 50% 100%, rgba(200, 190, 170, 0.05) 0%, transparent 50%);
}
-/* ── Scrollbar ── */
+#root {
+ min-height: 100vh;
+ isolation: isolate;
+}
+
+/* ═══════════════════════════════════════════
+ TYPOGRAPHY
+ ═══════════════════════════════════════════ */
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-display);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+ color: var(--text-primary);
+}
+
+p { margin: 0; }
+
+/* ═══════════════════════════════════════════
+ SCROLLBAR
+ ═══════════════════════════════════════════ */
+
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 99px; }
-::-webkit-scrollbar-thumb:hover { background: var(--accent-primary); opacity: 0.4; }
+::-webkit-scrollbar-thumb {
+ background: var(--border-hover);
+ border-radius: 99px;
+}
+::-webkit-scrollbar-thumb:hover { background: var(--accent-primary); }
-/* ── Typography ── */
-h1, h2, h3, h4 {
- @apply font-display font-extrabold tracking-tight;
- color: var(--text-primary);
+.no-scrollbar::-webkit-scrollbar { display: none; }
+.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
+
+/* ═══════════════════════════════════════════
+ COMPONENT CLASSES
+ ═══════════════════════════════════════════ */
+
+@layer components {
+ /* Glass surface */
+ .glass {
+ background: var(--bg-glass);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--border-color);
+ box-shadow: var(--shadow-card);
+ transition: background var(--transition-slow), border-color var(--transition-base), box-shadow var(--transition-base);
+ }
+
+ /* Elevated card */
+ .card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-card);
+ transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
+ }
+ .card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-card-hover);
+ border-color: var(--border-hover);
+ }
+
+ /* Input field */
+ .input {
+ width: 100%;
+ padding: 0.875rem 1.125rem;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-family: var(--font-body);
+ font-size: 0.9375rem;
+ outline: none;
+ transition: border-color var(--transition-base), box-shadow var(--transition-base), background var(--transition-base);
+ }
+ .input::placeholder { color: var(--text-muted); }
+ .input:focus {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+ background: var(--bg-elevated);
+ }
+
+ /* Primary button */
+ .btn-primary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
+ color: #fff;
+ font-family: var(--font-body);
+ font-weight: 700;
+ font-size: 0.8125rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ box-shadow: var(--shadow-glow);
+ transition: transform var(--transition-base), filter var(--transition-base), box-shadow var(--transition-base);
+ user-select: none;
+ }
+ .btn-primary:hover {
+ transform: translateY(-1px);
+ filter: brightness(1.1);
+ box-shadow: 0 0 32px var(--accent-glow);
+ }
+ .btn-primary:active { transform: scale(0.97); }
+ .btn-primary:disabled {
+ opacity: 0.5;
+ transform: none;
+ cursor: not-allowed;
+ filter: none;
+ }
+
+ /* Secondary button */
+ .btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ font-family: var(--font-body);
+ font-weight: 700;
+ font-size: 0.8125rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ user-select: none;
+ }
+ .btn-secondary:hover {
+ color: var(--text-primary);
+ border-color: var(--border-hover);
+ background: var(--bg-elevated);
+ }
+
+ /* Ghost button */
+ .btn-ghost {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ padding: 0.5rem 0.75rem;
+ background: transparent;
+ color: var(--text-secondary);
+ font-family: var(--font-body);
+ font-weight: 600;
+ font-size: 0.75rem;
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ user-select: none;
+ }
+ .btn-ghost:hover { color: var(--text-primary); background: var(--bg-surface); }
+
+ /* Badge */
+ .badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.625rem;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: 99px;
+ color: var(--text-secondary);
+ font-size: 0.625rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ }
+ .badge-accent {
+ background: var(--accent-glow);
+ border-color: var(--accent-primary);
+ color: var(--accent-primary);
+ }
}
-/* ── Glass card ── */
-.premium-glass {
- background: var(--bg-secondary);
- backdrop-filter: var(--glass-blur);
- -webkit-backdrop-filter: var(--glass-blur);
- border: 1px solid var(--border-color);
- box-shadow: var(--shadow-premium);
- transition:
- background 0.4s ease,
- border-color 0.4s ease,
- box-shadow 0.4s ease;
-}
-
-/* ── Inputs ── */
-.input-premium {
- @apply w-full px-5 py-4 rounded-xl transition-all outline-none;
- background: var(--input-bg);
- border: 1px solid var(--border-color);
- color: var(--text-primary);
-}
-.input-premium::placeholder { color: var(--text-secondary); opacity: 0.7; }
-.input-premium:focus {
- border-color: var(--accent-primary);
- box-shadow: 0 0 0 3px var(--accent-glow);
- background: var(--bg-secondary);
-}
-
-/* ── Neon button ── */
-.neon-button {
- @apply relative inline-flex items-center justify-center gap-2 px-6 py-3 rounded-xl
- font-bold text-white transition-all duration-300 active:scale-[0.97] select-none;
- background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
- box-shadow: 0 4px 20px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.15);
-}
-.neon-button:hover {
- transform: translateY(-1px);
- filter: brightness(1.1);
- box-shadow: 0 8px 28px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.2);
-}
-.neon-button:disabled {
- opacity: 0.5;
- transform: none;
- cursor: not-allowed;
-}
-
-/* ── Light theme: inverse player background ── */
-.dark-theme {
- --bg-primary: #000000;
- --bg-secondary: #0a0a0a;
-}
-/* Used as inverse wrapper inside AudioPlayer when in light mode */
-.player-inverse {
- --text-primary: #f1f5f9;
- --text-secondary: #94a3b8;
- --border-color: rgba(255, 255, 255, 0.12);
- --bg-secondary: rgba(255, 255, 255, 0.06);
- color: var(--text-primary);
-}
-
-/* ═══════════════════════════════════════════════════
+/* ═══════════════════════════════════════════
ANIMATIONS
-═══════════════════════════════════════════════════ */
+ ═══════════════════════════════════════════ */
@keyframes fade-in {
from { opacity: 0; }
@@ -179,43 +304,38 @@ h1, h2, h3, h4 {
}
@keyframes slide-up {
- from { opacity: 0; transform: translateY(20px); }
+ from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-down {
- from { opacity: 0; transform: translateY(-14px); }
+ from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
-@keyframes reveal {
- from { opacity: 0; transform: translateY(14px); }
- to { opacity: 1; transform: translateY(0); }
+@keyframes slide-left {
+ from { opacity: 0; transform: translateX(12px); }
+ to { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
- from { opacity: 0; transform: scale(0.93); }
+ from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
-@keyframes float {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-10px); }
-}
-
@keyframes shimmer {
0% { background-position: -200% 0; }
- 100% { background-position: 200% 0; }
+ 100% { background-position: 200% 0; }
}
-@keyframes aura-pulse {
- 0%, 100% { opacity: 0.4; transform: scale(1); }
- 50% { opacity: 0.75; transform: scale(1.1); }
+@keyframes float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-8px); }
}
-@keyframes glow-beat {
- 0%, 100% { box-shadow: 0 0 16px var(--accent-glow); }
- 50% { box-shadow: 0 0 36px var(--accent-glow); }
+@keyframes pulse-glow {
+ 0%, 100% { box-shadow: 0 0 12px var(--accent-glow); }
+ 50% { box-shadow: 0 0 28px var(--accent-glow); }
}
@keyframes spin-slow {
@@ -223,87 +343,90 @@ h1, h2, h3, h4 {
to { transform: rotate(360deg); }
}
-@keyframes ping-slow {
- 0% { transform: scale(1); opacity: 1; }
- 75%, 100% { transform: scale(2); opacity: 0; }
-}
-
-@keyframes gold-shimmer {
- 0% { background-position: -200% 0; }
- 100% { background-position: 200% 0; }
-}
-
-/* ── Animation utility classes ── */
-.reveal { animation: reveal 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
-.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
-.animate-slide-down { animation: slide-down 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
-.animate-fade-in { animation: fade-in 0.3s ease both; }
+/* Animation utility classes */
+.animate-fade-in { animation: fade-in 0.3s ease both; }
+.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
+.animate-slide-down { animation: slide-down 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
+.animate-slide-left { animation: slide-left 0.3s ease both; }
.animate-scale-in { animation: scale-in 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
-.animate-float { animation: float 3.5s ease-in-out infinite; }
-.animate-aura { animation: aura-pulse 10s ease-in-out infinite; }
-.animate-glow-beat { animation: glow-beat 3s ease-in-out infinite; }
-.animate-spin-slow { animation: spin-slow 10s linear infinite; }
-.animate-spin-paused { animation-play-state: paused; }
-.animate-ping-slow { animation: ping-slow 2.5s cubic-bezier(0, 0, 0.2, 1) infinite; }
+.animate-float { animation: float 4s ease-in-out infinite; }
+.animate-spin-slow { animation: spin-slow 12s linear infinite; }
-@keyframes eq-bar {
- 0%, 100% { transform: scaleY(0.25); }
- 50% { transform: scaleY(1); }
-}
-.animate-eq-bar {
- animation: eq-bar 0.9s ease-in-out infinite;
- transform-origin: bottom;
-}
-
-/* ── Shimmer loading skeleton ── */
-.shimmer {
+/* Shimmer skeleton */
+.skeleton {
background: linear-gradient(
90deg,
- var(--bg-secondary) 25%,
- rgba(255, 255, 255, 0.08) 50%,
- var(--bg-secondary) 75%
+ var(--bg-surface) 25%,
+ var(--bg-elevated) 50%,
+ var(--bg-surface) 75%
);
background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite;
+ border-radius: var(--radius-md);
}
-/* ── Gold shimmer accent (light theme decorative) ── */
-.gold-shimmer {
- background: linear-gradient(
- 90deg,
- transparent,
- rgba(184, 150, 12, 0.15),
- transparent
- );
- background-size: 200% 100%;
- animation: gold-shimmer 3s ease-in-out infinite;
-}
-
-/* ═══════════════════════════════════════════════════
- STAGGER GRID
- Add class "stagger-grid" to a grid container —
- children animate in with a cascade delay.
-═══════════════════════════════════════════════════ */
-.stagger-grid > * {
+/* Stagger grid */
+.stagger > * {
animation: slide-up 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
-.stagger-grid > *:nth-child(1) { animation-delay: 0ms; }
-.stagger-grid > *:nth-child(2) { animation-delay: 65ms; }
-.stagger-grid > *:nth-child(3) { animation-delay: 130ms; }
-.stagger-grid > *:nth-child(4) { animation-delay: 195ms; }
-.stagger-grid > *:nth-child(5) { animation-delay: 260ms; }
-.stagger-grid > *:nth-child(6) { animation-delay: 325ms; }
-.stagger-grid > *:nth-child(7) { animation-delay: 390ms; }
-.stagger-grid > *:nth-child(8) { animation-delay: 455ms; }
-.stagger-grid > *:nth-child(9) { animation-delay: 520ms; }
-.stagger-grid > *:nth-child(10) { animation-delay: 585ms; }
-.stagger-grid > *:nth-child(11) { animation-delay: 650ms; }
-.stagger-grid > *:nth-child(12) { animation-delay: 715ms; }
-.stagger-grid > *:nth-child(n+13) { animation-delay: 780ms; }
+.stagger > *:nth-child(1) { animation-delay: 0ms; }
+.stagger > *:nth-child(2) { animation-delay: 55ms; }
+.stagger > *:nth-child(3) { animation-delay: 110ms; }
+.stagger > *:nth-child(4) { animation-delay: 165ms; }
+.stagger > *:nth-child(5) { animation-delay: 220ms; }
+.stagger > *:nth-child(6) { animation-delay: 275ms; }
+.stagger > *:nth-child(7) { animation-delay: 330ms; }
+.stagger > *:nth-child(8) { animation-delay: 385ms; }
+.stagger > *:nth-child(9) { animation-delay: 440ms; }
+.stagger > *:nth-child(10) { animation-delay: 495ms; }
+.stagger > *:nth-child(11) { animation-delay: 550ms; }
+.stagger > *:nth-child(12) { animation-delay: 605ms; }
+.stagger > *:nth-child(n+13) { animation-delay: 660ms; }
-/* ── Utilities ── */
-.text-balance { text-wrap: balance; }
-.shadow-glass { box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4); }
-.shadow-glow-indigo { box-shadow: 0 0 20px var(--accent-glow); }
-.shadow-glow-cyan { box-shadow: 0 0 20px rgba(6, 182, 212, 0.35); }
-.shadow-glow-gold { box-shadow: 0 0 20px rgba(184, 150, 12, 0.4); }
+/* ═══════════════════════════════════════════
+ AUDIO PLAYER SPECIFIC
+ ═══════════════════════════════════════════ */
+
+.player-artwork-ring {
+ box-shadow:
+ 0 0 0 4px var(--bg-base),
+ 0 0 0 5px var(--border-color),
+ 0 8px 32px rgba(0, 0, 0, 0.5);
+}
+
+/* Range input styling */
+input[type="range"] {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 4px;
+ background: var(--border-color);
+ border-radius: 99px;
+ outline: none;
+ cursor: pointer;
+}
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--accent-primary);
+ box-shadow: 0 0 8px var(--accent-glow);
+ cursor: pointer;
+ transition: transform var(--transition-base);
+}
+input[type="range"]::-webkit-slider-thumb:hover {
+ transform: scale(1.2);
+}
+
+/* ═══════════════════════════════════════════
+ UTILITIES
+ ═══════════════════════════════════════════ */
+
+.text-balance { text-wrap: balance; }
+.truncate-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 6ab5587..40ce007 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -7,50 +7,41 @@ export default {
theme: {
extend: {
colors: {
- obsidian: {
- DEFAULT: '#020617',
- 900: '#020617',
- 800: '#0f172a',
- 700: '#1e293b',
+ brand: {
+ indigo: '#6366f1',
+ cyan: '#06b6d4',
+ rose: '#f43f5e',
+ gold: '#f59e0b',
+ emerald: '#10b981',
+ violet: '#8b5cf6',
},
- accent: {
- indigo: '#6366f1',
- cyan: '#06b6d4',
- rose: '#f43f5e',
- violet: '#8b5cf6',
- gold: '#b8960c',
+ surface: {
+ base: 'var(--bg-base)',
+ surface: 'var(--bg-surface)',
+ elevated:'var(--bg-elevated)',
+ glass: 'var(--bg-glass)',
},
- light: {
- 50: '#f8fafc',
- 100: '#f1f5f9',
- 200: '#e2e8f0',
- 300: '#cbd5e1',
- 400: '#94a3b8',
- 500: '#64748b',
- 600: '#475569',
- 700: '#334155',
- 800: '#1e293b',
- 900: '#0f172a',
- }
},
fontFamily: {
- sans: ['"Outfit"', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
+ sans: ['"Outfit"', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
display: ['"Montserrat"', 'sans-serif'],
},
borderRadius: {
- 'xl': '0.75rem', /* 12px */
- '2xl': '1rem', /* 16px — matches --radius-card */
- '3xl': '1.25rem', /* 20px — matches --radius-panel */
- '4xl': '1.5rem', /* 24px */
+ 'sm': 'var(--radius-sm)',
+ 'md': 'var(--radius-md)',
+ 'lg': 'var(--radius-lg)',
+ 'xl': 'var(--radius-xl)',
},
- boxShadow: {
- 'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
- 'glow-indigo': '0 0 20px rgba(99, 102, 241, 0.3)',
- 'glow-cyan': '0 0 20px rgba(6, 182, 212, 0.3)',
+ animation: {
+ 'fade-in': 'fade-in 0.3s ease both',
+ 'slide-up': 'slide-up 0.4s cubic-bezier(0.2,0.8,0.2,1) both',
+ 'slide-down': 'slide-down 0.35s cubic-bezier(0.2,0.8,0.2,1) both',
+ 'slide-left': 'slide-left 0.3s ease both',
+ 'scale-in': 'scale-in 0.35s cubic-bezier(0.2,0.8,0.2,1) both',
+ 'float': 'float 4s ease-in-out infinite',
+ 'spin-slow': 'spin-slow 12s linear infinite',
+ 'shimmer': 'shimmer 1.6s ease-in-out infinite',
},
- backdropBlur: {
- 'xs': '2px',
- }
},
},
plugins: [],