mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-21 22:18:03 +02:00
295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { podcastService, Podcast } from '../services/podcastService';
|
|
import { authService } from '../services/authService';
|
|
import Header from '../components/Header';
|
|
import { BookOpen, Trash2, Loader, Rss } from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
|
|
export default function Library() {
|
|
const queryClient = useQueryClient();
|
|
const [user, setUser] = useState<any>(null);
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const loadUser = async () => {
|
|
try {
|
|
const response = await authService.getMe();
|
|
setUser(response.user);
|
|
} catch (error) {
|
|
console.error('Failed to load user:', error);
|
|
}
|
|
};
|
|
loadUser();
|
|
}, []);
|
|
|
|
const { data: subscriptionsData, isLoading } = useQuery({
|
|
queryKey: ['podcasts', 'subscriptions'],
|
|
queryFn: () => podcastService.getUserSubscriptions(),
|
|
staleTime: 2 * 60 * 1000,
|
|
});
|
|
|
|
const unsubscribeMutation = useMutation({
|
|
mutationFn: (podcastId: string) => podcastService.unsubscribe(podcastId),
|
|
onMutate: async (podcastId: string) => {
|
|
// Cancel outgoing refetches to avoid overwriting our optimistic update
|
|
await queryClient.cancelQueries({ queryKey: ['podcasts', 'subscriptions'] });
|
|
|
|
// Optimistic update: remove the podcast from the local cache immediately
|
|
queryClient.setQueryData(
|
|
['podcasts', 'subscriptions'],
|
|
(old: any) => {
|
|
if (!old || !old.podcasts) return old;
|
|
// Optimistically filter by subscriptionId or _id as fallback
|
|
const filtered = old.podcasts.filter((p: any) =>
|
|
String(p.subscriptionId || p._id) !== String(podcastId)
|
|
);
|
|
return { ...old, podcasts: filtered, count: filtered.length };
|
|
}
|
|
);
|
|
|
|
setConfirmDeleteId(null);
|
|
},
|
|
onSuccess: () => {
|
|
// Sync the server state after success
|
|
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
|
|
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
|
|
},
|
|
onError: (error: any, podcastId) => {
|
|
console.error(`[Unsubscribe FAILED] Podcast ID: ${podcastId}`, error);
|
|
// Rollback: re-fetch the real data from server if the mutation failed
|
|
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
|
|
|
|
const errorMessage = error.response?.data?.message || 'Erreur inconnue';
|
|
alert(`Erreur lors du désabonnement (ID: ${podcastId}) : ${errorMessage}`);
|
|
},
|
|
});
|
|
|
|
const syncMutation = useMutation({
|
|
mutationFn: (podcastId: string) => podcastService.syncPodcast(podcastId),
|
|
onSuccess: (data, podcastId) => {
|
|
console.log(`[Sync SUCCESS] Podcast ID: ${podcastId}`, data);
|
|
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
|
|
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
|
|
},
|
|
onError: (error: any, podcastId) => {
|
|
console.error(`[Sync FAILED] Podcast ID: ${podcastId}`, error);
|
|
const errorMessage = error.response?.data?.message || 'Erreur de synchronisation';
|
|
alert(`Erreur : ${errorMessage}`);
|
|
},
|
|
});
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await authService.logout();
|
|
authService.clearTokens();
|
|
window.location.href = '/login';
|
|
} catch (error) {
|
|
console.error('Logout failed:', error);
|
|
}
|
|
};
|
|
|
|
const handleUnsubscribeRequest = (id: string) => {
|
|
console.log("[UI] Demande de désabonnement reçue pour l'ID (Sub/Pod):", id);
|
|
setConfirmDeleteId(id);
|
|
};
|
|
|
|
const handleConfirmUnsubscribe = () => {
|
|
if (confirmDeleteId) {
|
|
console.log("[UI] Confirmation du désabonnement pour l'ID:", confirmDeleteId);
|
|
unsubscribeMutation.mutate(confirmDeleteId);
|
|
}
|
|
};
|
|
|
|
const podcasts: Podcast[] = subscriptionsData?.podcasts ?? [];
|
|
|
|
return (
|
|
<div className="min-h-screen bg-light-50">
|
|
<Header
|
|
title="Ma Bibliothèque"
|
|
subtitle="Gérez vos abonnements aux podcasts"
|
|
user={user}
|
|
onLogout={handleLogout}
|
|
/>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
{/* Title row */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-2">
|
|
<BookOpen className="w-6 h-6 text-blue-500" />
|
|
<h2 className="text-xl font-bold text-light-900">
|
|
{isLoading ? 'Chargement...' : `${podcasts.length} abonnement${podcasts.length !== 1 ? 's' : ''}`}
|
|
</h2>
|
|
</div>
|
|
<Link
|
|
to="/add"
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors font-medium text-sm"
|
|
>
|
|
<Rss className="w-4 h-4" />
|
|
Ajouter un podcast
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="text-center">
|
|
<Loader className="w-12 h-12 text-blue-500 animate-spin mx-auto mb-3" />
|
|
<p className="text-light-600">Chargement de vos abonnements...</p>
|
|
</div>
|
|
</div>
|
|
) : podcasts.length === 0 ? (
|
|
<div className="text-center py-20">
|
|
<BookOpen className="w-16 h-16 text-light-300 mx-auto mb-4" />
|
|
<p className="text-light-600 text-lg mb-2">Votre bibliothèque est vide</p>
|
|
<p className="text-light-500 mb-6">Abonnez-vous à des podcasts pour les retrouver ici</p>
|
|
<Link
|
|
to="/add"
|
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-blue-500 text-white hover:bg-blue-600 transition-colors font-medium"
|
|
>
|
|
<Rss className="w-5 h-5" />
|
|
Ajouter mon premier podcast
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{podcasts.map((podcast) => (
|
|
<div key={podcast._id} className="card bg-white hover:shadow-lg transition-shadow flex flex-col">
|
|
{/* Podcast cover */}
|
|
{podcast.imageUrl ? (
|
|
<div className="mb-4 rounded-lg overflow-hidden aspect-square w-24 h-24 bg-light-200 flex-shrink-0">
|
|
<img
|
|
src={podcast.imageUrl}
|
|
alt={podcast.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="mb-4 w-24 h-24 rounded-lg bg-light-200 flex items-center justify-center flex-shrink-0">
|
|
<Rss className="w-8 h-8 text-light-400" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-base font-bold text-light-900 mb-1 truncate">
|
|
{podcast.title}
|
|
</h3>
|
|
{podcast.author && (
|
|
<p className="text-xs text-light-500 mb-2 truncate">{podcast.author}</p>
|
|
)}
|
|
|
|
{/* Description preview */}
|
|
{podcast.description && (
|
|
<p className="text-xs text-light-400 line-clamp-2 mb-3 h-8 leading-relaxed">
|
|
{podcast.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2 text-[10px] text-light-400 mb-4">
|
|
{podcast.episodeCount > 0 && (
|
|
<span className="bg-light-100 px-2 py-0.5 rounded-full">
|
|
{podcast.episodeCount} épisodes
|
|
</span>
|
|
)}
|
|
{podcast.language && (
|
|
<span className="bg-light-100 px-2 py-0.5 rounded-full uppercase">
|
|
{podcast.language}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Last Episode info */}
|
|
{podcast.lastEpisodeTitle && (
|
|
<div className="bg-blue-50/50 rounded-lg p-2.5 mb-2 border border-blue-100/50">
|
|
<p className="text-[10px] font-semibold text-blue-600 uppercase tracking-wider mb-1">
|
|
Dernier épisode
|
|
</p>
|
|
<p className="text-xs font-medium text-light-800 line-clamp-1 mb-0.5">
|
|
{podcast.lastEpisodeTitle}
|
|
</p>
|
|
{podcast.lastEpisodeDate && (
|
|
<p className="text-[10px] text-light-400">
|
|
{new Date(podcast.lastEpisodeDate).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Unsubscribe button */}
|
|
{confirmDeleteId === (podcast.subscriptionId || podcast._id) ? (
|
|
<div className="flex items-center gap-2 mt-auto pt-3 border-t border-light-100">
|
|
<span className="text-xs text-light-600 flex-1">Confirmer le désabonnement ?</span>
|
|
<button
|
|
onClick={handleConfirmUnsubscribe}
|
|
disabled={unsubscribeMutation.isPending}
|
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors text-xs font-medium"
|
|
>
|
|
{unsubscribeMutation.isPending ? (
|
|
<Loader className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-3 h-3" />
|
|
)}
|
|
Confirmer
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmDeleteId(null)}
|
|
className="px-3 py-1.5 rounded-lg bg-light-200 text-light-700 hover:bg-light-300 transition-colors text-xs font-medium"
|
|
>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-auto">
|
|
{podcast.episodeCount === 0 && (
|
|
<div className="mb-4">
|
|
<p className="text-[10px] text-orange-500 font-semibold mb-2 flex items-center gap-1 uppercase tracking-wider">
|
|
{syncMutation.isPending && syncMutation.variables === (podcast.subscriptionId || podcast._id) ? (
|
|
<Loader className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
|
|
)}
|
|
Aucun épisode trouvé
|
|
</p>
|
|
<button
|
|
onClick={() => syncMutation.mutate(podcast._id)}
|
|
disabled={syncMutation.isPending}
|
|
className="w-full py-2 rounded-xl bg-orange-50 text-orange-600 hover:bg-orange-100 transition-all text-xs font-bold flex items-center justify-center gap-2 border border-orange-200/50"
|
|
>
|
|
{syncMutation.isPending && syncMutation.variables === podcast._id ? (
|
|
<>
|
|
<Loader className="w-3 h-3 animate-spin" />
|
|
Synchronisation...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Loader className="w-3 h-3" />
|
|
Synchroniser maintenant
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => handleUnsubscribeRequest(podcast.subscriptionId || podcast._id)}
|
|
className="pt-3 border-t border-light-100 flex items-center gap-2 text-sm text-light-500 hover:text-red-600 transition-colors w-full"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Se désabonner
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|