551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { AlertCircle, Save, User, Lock, LogOut, Trash2 } from "lucide-react";
|
|
import { apiService } from "@/api/base";
|
|
import { recipeService, Recipe } from "@/api/recipe";
|
|
|
|
// Types pour les données utilisateur
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
username?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
bio?: string;
|
|
avatarUrl?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
// Service utilisateur
|
|
const userService = {
|
|
getCurrentUser: async (): Promise<User> => {
|
|
return apiService.get<User>('users/me');
|
|
},
|
|
|
|
updateProfile: async (data: Partial<User>): Promise<User> => {
|
|
return apiService.put<User>('users/me', data);
|
|
},
|
|
|
|
changePassword: async (data: { currentPassword: string; newPassword: string }): Promise<void> => {
|
|
return apiService.post('users/change-password', data);
|
|
},
|
|
|
|
deleteAccount: async (): Promise<void> => {
|
|
return apiService.delete('users/me');
|
|
},
|
|
|
|
logout: async (): Promise<void> => {
|
|
return apiService.post('auth/logout', {});
|
|
}
|
|
};
|
|
|
|
export default function Profile() {
|
|
const navigate = useNavigate();
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [userRecipes, setUserRecipes] = useState<Recipe[]>([]);
|
|
const [favoriteRecipes, setFavoriteRecipes] = useState<Recipe[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
|
|
// États pour le formulaire de profil
|
|
const [profileForm, setProfileForm] = useState({
|
|
username: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
bio: ""
|
|
});
|
|
|
|
// États pour le formulaire de mot de passe
|
|
const [passwordForm, setPasswordForm] = useState({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: ""
|
|
});
|
|
|
|
useEffect(() => {
|
|
const fetchUserData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Récupérer les données de l'utilisateur
|
|
const userData = await userService.getCurrentUser();
|
|
setUser(userData);
|
|
setProfileForm({
|
|
username: userData.username || "",
|
|
firstName: userData.firstName || "",
|
|
lastName: userData.lastName || "",
|
|
bio: userData.bio || ""
|
|
});
|
|
|
|
// Récupérer les recettes de l'utilisateur
|
|
const recipes = await recipeService.getUserRecipes();
|
|
setUserRecipes(recipes);
|
|
|
|
// Récupérer les recettes favorites
|
|
const favorites = await recipeService.getFavoriteRecipes();
|
|
setFavoriteRecipes(favorites);
|
|
} catch (err) {
|
|
console.error("Erreur lors du chargement du profil:", err);
|
|
setError("Impossible de charger les données du profil");
|
|
|
|
// Rediriger vers la page de connexion si non authentifié
|
|
if (err instanceof Error && err.message.includes("401")) {
|
|
localStorage.removeItem("token");
|
|
navigate("/auth/login");
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchUserData();
|
|
}, [navigate]);
|
|
|
|
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const { name, value } = e.target;
|
|
setProfileForm(prev => ({ ...prev, [name]: value }));
|
|
};
|
|
|
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value } = e.target;
|
|
setPasswordForm(prev => ({ ...prev, [name]: value }));
|
|
};
|
|
|
|
const handleProfileSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
setSuccess("");
|
|
|
|
try {
|
|
setSaving(true);
|
|
const updatedUser = await userService.updateProfile(profileForm);
|
|
setUser(updatedUser);
|
|
setSuccess("Profil mis à jour avec succès");
|
|
} catch (err) {
|
|
console.error("Erreur lors de la mise à jour du profil:", err);
|
|
setError("Impossible de mettre à jour le profil");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
setSuccess("");
|
|
|
|
// Vérifier que les mots de passe correspondent
|
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
|
setError("Les mots de passe ne correspondent pas");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSaving(true);
|
|
await userService.changePassword({
|
|
currentPassword: passwordForm.currentPassword,
|
|
newPassword: passwordForm.newPassword
|
|
});
|
|
setSuccess("Mot de passe modifié avec succès");
|
|
setPasswordForm({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: ""
|
|
});
|
|
} catch (err) {
|
|
console.error("Erreur lors du changement de mot de passe:", err);
|
|
setError("Impossible de changer le mot de passe");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await userService.logout();
|
|
} catch (err) {
|
|
console.error("Erreur lors de la déconnexion:", err);
|
|
} finally {
|
|
localStorage.removeItem("token");
|
|
navigate("/auth/login");
|
|
}
|
|
};
|
|
|
|
const handleDeleteAccount = async () => {
|
|
if (!window.confirm("Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await userService.deleteAccount();
|
|
localStorage.removeItem("token");
|
|
navigate("/auth/login");
|
|
} catch (err) {
|
|
console.error("Erreur lors de la suppression du compte:", err);
|
|
setError("Impossible de supprimer le compte");
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex justify-center py-12">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="rounded-md bg-red-50 p-6 text-center text-red-700 dark:bg-red-900/50 dark:text-red-200">
|
|
<h2 className="text-xl font-bold">Erreur</h2>
|
|
<p>Utilisateur non trouvé ou non connecté</p>
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={() => navigate("/auth/login")}
|
|
>
|
|
Se connecter
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Profil</h1>
|
|
<p className="text-muted-foreground">
|
|
Gérez vos informations personnelles et vos préférences
|
|
</p>
|
|
</div>
|
|
<Button variant="destructive" onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
Déconnexion
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Erreur</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{success && (
|
|
<Alert className="bg-green-50 text-green-700 dark:bg-green-900/50 dark:text-green-200">
|
|
<AlertTitle>Succès</AlertTitle>
|
|
<AlertDescription>{success}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-8 md:flex-row">
|
|
<div className="md:w-1/3">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Informations</CardTitle>
|
|
<CardDescription>Vos informations de compte</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col items-center space-y-4">
|
|
<Avatar className="h-24 w-24">
|
|
<AvatarImage src={user.avatarUrl} alt={user.username || user.email} />
|
|
<AvatarFallback className="text-2xl">
|
|
{user.firstName?.[0]}{user.lastName?.[0] || user.email[0].toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="text-center">
|
|
<h3 className="text-xl font-bold">{user.username || "Utilisateur"}</h3>
|
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
|
{(user.firstName || user.lastName) && (
|
|
<p className="text-sm">{user.firstName} {user.lastName}</p>
|
|
)}
|
|
</div>
|
|
<div className="w-full">
|
|
<p className="text-sm text-muted-foreground">Membre depuis</p>
|
|
<p>{new Date(user.createdAt).toLocaleDateString()}</p>
|
|
</div>
|
|
{user.bio && (
|
|
<div className="w-full">
|
|
<p className="text-sm text-muted-foreground">Bio</p>
|
|
<p className="text-sm">{user.bio}</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter>
|
|
<Button
|
|
variant="destructive"
|
|
className="w-full"
|
|
onClick={handleDeleteAccount}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Supprimer mon compte
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<Tabs defaultValue="profile">
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="profile">
|
|
<User className="mr-2 h-4 w-4" />
|
|
Profil
|
|
</TabsTrigger>
|
|
<TabsTrigger value="security">
|
|
<Lock className="mr-2 h-4 w-4" />
|
|
Sécurité
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="profile" className="mt-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Informations du profil</CardTitle>
|
|
<CardDescription>
|
|
Mettez à jour vos informations personnelles
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="firstName">Prénom</Label>
|
|
<Input
|
|
id="firstName"
|
|
name="firstName"
|
|
value={profileForm.firstName}
|
|
onChange={handleProfileChange}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="lastName">Nom</Label>
|
|
<Input
|
|
id="lastName"
|
|
name="lastName"
|
|
value={profileForm.lastName}
|
|
onChange={handleProfileChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="username">Nom d'utilisateur</Label>
|
|
<Input
|
|
id="username"
|
|
name="username"
|
|
value={profileForm.username}
|
|
onChange={handleProfileChange}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bio">Bio</Label>
|
|
<textarea
|
|
id="bio"
|
|
name="bio"
|
|
value={profileForm.bio}
|
|
onChange={handleProfileChange}
|
|
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
<Button type="submit" disabled={saving}>
|
|
{saving ? (
|
|
<>
|
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
|
Enregistrement...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
Enregistrer les modifications
|
|
</>
|
|
)}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="security" className="mt-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sécurité du compte</CardTitle>
|
|
<CardDescription>
|
|
Mettez à jour votre mot de passe
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="currentPassword">Mot de passe actuel</Label>
|
|
<Input
|
|
id="currentPassword"
|
|
name="currentPassword"
|
|
type="password"
|
|
value={passwordForm.currentPassword}
|
|
onChange={handlePasswordChange}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="newPassword">Nouveau mot de passe</Label>
|
|
<Input
|
|
id="newPassword"
|
|
name="newPassword"
|
|
type="password"
|
|
value={passwordForm.newPassword}
|
|
onChange={handlePasswordChange}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
|
<Input
|
|
id="confirmPassword"
|
|
name="confirmPassword"
|
|
type="password"
|
|
value={passwordForm.confirmPassword}
|
|
onChange={handlePasswordChange}
|
|
required
|
|
/>
|
|
</div>
|
|
<Button type="submit" disabled={saving}>
|
|
{saving ? (
|
|
<>
|
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
|
Enregistrement...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
Changer le mot de passe
|
|
</>
|
|
)}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="mt-8 space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Mes recettes</h2>
|
|
<Separator className="my-4" />
|
|
|
|
{userRecipes.length === 0 ? (
|
|
<div className="rounded-lg border p-6 text-center">
|
|
<p className="text-muted-foreground">Vous n'avez pas encore créé de recettes</p>
|
|
<Button
|
|
className="mt-4"
|
|
onClick={() => navigate("/recipes/create")}
|
|
>
|
|
Créer une recette
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{userRecipes.slice(0, 3).map(recipe => (
|
|
<Card key={recipe.id} className="overflow-hidden">
|
|
<div className="aspect-video w-full overflow-hidden">
|
|
<img
|
|
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
|
|
alt={recipe.title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</div>
|
|
<CardHeader className="p-4">
|
|
<CardTitle className="text-lg">{recipe.title}</CardTitle>
|
|
</CardHeader>
|
|
<CardFooter className="p-4 pt-0">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
|
>
|
|
Voir la recette
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
))}
|
|
|
|
{userRecipes.length > 3 && (
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={() => navigate("/my-recipes")}
|
|
>
|
|
Voir toutes mes recettes ({userRecipes.length})
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Mes favoris</h2>
|
|
<Separator className="my-4" />
|
|
|
|
{favoriteRecipes.length === 0 ? (
|
|
<div className="rounded-lg border p-6 text-center">
|
|
<p className="text-muted-foreground">Vous n'avez pas encore de recettes favorites</p>
|
|
<Button
|
|
className="mt-4"
|
|
onClick={() => navigate("/recipes")}
|
|
>
|
|
Explorer les recettes
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{favoriteRecipes.slice(0, 3).map(recipe => (
|
|
<Card key={recipe.id} className="overflow-hidden">
|
|
<div className="aspect-video w-full overflow-hidden">
|
|
<img
|
|
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
|
|
alt={recipe.title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</div>
|
|
<CardHeader className="p-4">
|
|
<CardTitle className="text-lg">{recipe.title}</CardTitle>
|
|
</CardHeader>
|
|
<CardFooter className="p-4 pt-0">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
|
>
|
|
Voir la recette
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
))}
|
|
|
|
{favoriteRecipes.length > 3 && (
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={() => navigate("/favorites")}
|
|
>
|
|
Voir tous mes favoris ({favoriteRecipes.length})
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|