From fb7ddde42e50b0eb8cfc7a30f7ce26565fc2f360 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:43:27 +0100 Subject: [PATCH] feat(app): add dashboard pages for settings, admin, and public user profiles Introduce new pages for profile settings, admin dashboard (users, contents, categories), and public user profiles. Enhance profile functionality with avatar uploads and bio updates. Include help and improved content trends/recent pages. Streamline content display using `HomeContent`. --- .../app/(dashboard)/admin/categories/page.tsx | 72 +++++++ .../app/(dashboard)/admin/contents/page.tsx | 137 +++++++++++++ frontend/src/app/(dashboard)/admin/page.tsx | 85 ++++++++ .../src/app/(dashboard)/admin/users/page.tsx | 123 ++++++++++++ frontend/src/app/(dashboard)/help/page.tsx | 72 +++++++ frontend/src/app/(dashboard)/profile/page.tsx | 56 +++++- frontend/src/app/(dashboard)/recent/page.tsx | 21 +- .../src/app/(dashboard)/settings/page.tsx | 185 ++++++++++++++++++ frontend/src/app/(dashboard)/trends/page.tsx | 21 +- .../app/(dashboard)/user/[username]/page.tsx | 98 ++++++++++ 10 files changed, 842 insertions(+), 28 deletions(-) create mode 100644 frontend/src/app/(dashboard)/admin/categories/page.tsx create mode 100644 frontend/src/app/(dashboard)/admin/contents/page.tsx create mode 100644 frontend/src/app/(dashboard)/admin/page.tsx create mode 100644 frontend/src/app/(dashboard)/admin/users/page.tsx create mode 100644 frontend/src/app/(dashboard)/help/page.tsx create mode 100644 frontend/src/app/(dashboard)/settings/page.tsx create mode 100644 frontend/src/app/(dashboard)/user/[username]/page.tsx diff --git a/frontend/src/app/(dashboard)/admin/categories/page.tsx b/frontend/src/app/(dashboard)/admin/categories/page.tsx new file mode 100644 index 0000000..a1acb88 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/categories/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CategoryService } from "@/services/category.service"; +import type { Category } from "@/types/content"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AdminCategoriesPage() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + CategoryService.getAll() + .then(setCategories) + .catch(err => console.error(err)) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+

Catégories ({categories.length})

+
+
+ + + + Nom + Slug + Description + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + )) + ) : categories.length === 0 ? ( + + + Aucune catégorie trouvée. + + + ) : ( + categories.map((category) => ( + + {category.name} + {category.slug} + + {category.description || "Aucune description"} + + + )) + )} + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/contents/page.tsx b/frontend/src/app/(dashboard)/admin/contents/page.tsx new file mode 100644 index 0000000..f07d495 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/contents/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ContentService } from "@/services/content.service"; +import type { Content } from "@/types/content"; +import { Badge } from "@/components/ui/badge"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Eye, Download, Image as ImageIcon, Video, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export default function AdminContentsPage() { + const [contents, setContents] = useState([]); + const [loading, setLoading] = useState(true); + const [totalCount, setTotalCount] = useState(0); + + useEffect(() => { + ContentService.getExplore({ limit: 20 }) + .then((res) => { + setContents(res.data); + setTotalCount(res.total); + }) + .catch(err => console.error(err)) + .finally(() => setLoading(false)); + }, []); + + const handleDelete = async (id: string) => { + if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return; + + try { + await ContentService.removeAdmin(id); + setContents(contents.filter(c => c.id !== id)); + setTotalCount(prev => prev - 1); + } catch (error) { + console.error(error); + } + }; + + return ( +
+
+

Contenus ({totalCount})

+
+
+ + + + Contenu + Catégorie + Auteur + Stats + Date + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + )) + ) : contents.length === 0 ? ( + + + Aucun contenu trouvé. + + + ) : ( + contents.map((content) => ( + + +
+
+ {content.type === "image" ? ( + + ) : ( +
+
+
{content.title}
+
{content.type} • {content.mimeType}
+
+
+
+ + {content.category.name} + + + @{content.author.username} + + +
+
+ {content.views} +
+
+ {content.usageCount} +
+
+
+ + {format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })} + + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/page.tsx b/frontend/src/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..1043e24 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { adminService, type AdminStats } from "@/services/admin.service"; +import { Users, FileText, LayoutGrid, AlertCircle } from "lucide-react"; +import Link from "next/link"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AdminDashboardPage() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + adminService + .getStats() + .then(setStats) + .catch((err) => { + console.error(err); + setError("Impossible de charger les statistiques."); + }) + .finally(() => setLoading(false)); + }, []); + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + const statCards = [ + { + title: "Utilisateurs", + value: stats?.users, + icon: Users, + href: "/admin/users", + color: "text-blue-500", + }, + { + title: "Contenus", + value: stats?.contents, + icon: FileText, + href: "/admin/contents", + color: "text-green-500", + }, + { + title: "Catégories", + value: stats?.categories, + icon: LayoutGrid, + href: "/admin/categories", + color: "text-purple-500", + }, + ]; + + return ( +
+
+

Dashboard Admin

+
+
+ {statCards.map((card) => ( + + + + {card.title} + + + + {loading ? ( + + ) : ( +
{card.value}
+ )} +
+
+ + ))} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/page.tsx b/frontend/src/app/(dashboard)/admin/users/page.tsx new file mode 100644 index 0000000..48df000 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { UserService } from "@/services/user.service"; +import type { User } from "@/types/user"; +import { Badge } from "@/components/ui/badge"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { Trash2 } from "lucide-react"; + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [totalCount, setTotalCount] = useState(0); + + useEffect(() => { + UserService.getUsersAdmin() + .then((res) => { + setUsers(res.data); + setTotalCount(res.totalCount); + }) + .catch(err => { + console.error(err); + }) + .finally(() => setLoading(false)); + }, []); + + const handleDelete = async (uuid: string) => { + if (!confirm("Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.")) return; + + try { + await UserService.removeUserAdmin(uuid); + setUsers(users.filter(u => u.uuid !== uuid)); + setTotalCount(prev => prev - 1); + } catch (error) { + console.error(error); + } + }; + + return ( +
+
+

Utilisateurs ({totalCount})

+
+
+ + + + Utilisateur + Email + Rôle + Status + Date d'inscription + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + )) + ) : users.length === 0 ? ( + + + Aucun utilisateur trouvé. + + + ) : ( + users.map((user) => ( + + + {user.displayName || user.username} +
@{user.username}
+
+ {user.email} + + + {user.role} + + + + + {user.status} + + + + {format(new Date(user.createdAt), "PPP", { locale: fr })} + + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/help/page.tsx b/frontend/src/app/(dashboard)/help/page.tsx new file mode 100644 index 0000000..53f66b3 --- /dev/null +++ b/frontend/src/app/(dashboard)/help/page.tsx @@ -0,0 +1,72 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { HelpCircle } from "lucide-react"; + +export default function HelpPage() { + const faqs = [ + { + question: "Comment puis-je publier un mème ?", + answer: + "Pour publier un mème, vous devez être connecté à votre compte. Cliquez sur le bouton 'Publier' dans la barre latérale, choisissez votre fichier (image ou GIF), donnez-lui un titre et une catégorie, puis validez.", + }, + { + question: "Quels formats de fichiers sont acceptés ?", + answer: + "Nous acceptons les images au format PNG, JPEG, WebP et les GIF animés. La taille maximale recommandée est de 2 Mo.", + }, + { + question: "Comment fonctionnent les favoris ?", + answer: + "En cliquant sur l'icône de cœur sur un mème, vous l'ajoutez à vos favoris. Vous pouvez retrouver tous vos mèmes favoris dans l'onglet 'Mes Favoris' de votre profil.", + }, + { + question: "Puis-je supprimer un mème que j'ai publié ?", + answer: + "Oui, vous pouvez supprimer vos propres mèmes en vous rendant sur votre profil, en sélectionnant le mème et en cliquant sur l'option de suppression.", + }, + { + question: "Comment fonctionne le système de recherche ?", + answer: + "Vous pouvez rechercher des mèmes par titre en utilisant la barre de recherche dans la colonne de droite. Vous pouvez également filtrer par catégories ou par tags populaires.", + }, + ]; + + return ( +
+
+
+ +
+

Centre d'aide

+
+ +
+

Foire Aux Questions

+ + {faqs.map((faq, index) => ( + + + {faq.question} + + + {faq.answer} + + + ))} + +
+ +
+

Vous ne trouvez pas de réponse ?

+

+ N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. +

+

contact@memegoat.local

+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/profile/page.tsx b/frontend/src/app/(dashboard)/profile/page.tsx index e2f6765..3eda480 100644 --- a/frontend/src/app/(dashboard)/profile/page.tsx +++ b/frontend/src/app/(dashboard)/profile/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Calendar, LogIn, LogOut, Settings } from "lucide-react"; +import { Calendar, Camera, LogIn, LogOut, Settings } from "lucide-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import * as React from "react"; @@ -19,11 +19,32 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAuth } from "@/providers/auth-provider"; import { ContentService } from "@/services/content.service"; import { FavoriteService } from "@/services/favorite.service"; +import { UserService } from "@/services/user.service"; +import { toast } from "sonner"; export default function ProfilePage() { - const { user, isAuthenticated, isLoading, logout } = useAuth(); + const { user, isAuthenticated, isLoading, logout, refreshUser } = useAuth(); const searchParams = useSearchParams(); const tab = searchParams.get("tab") || "memes"; + const fileInputRef = React.useRef(null); + + const handleAvatarClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + await UserService.updateAvatar(file); + toast.success("Avatar mis à jour avec succès !"); + await refreshUser?.(); + } catch (error) { + console.error(error); + toast.error("Erreur lors de la mise à jour de l'avatar."); + } + }; const fetchMyMemes = React.useCallback( (params: { limit: number; offset: number }) => @@ -72,12 +93,28 @@ export default function ProfilePage() {
- - - - {user.username.slice(0, 2).toUpperCase()} - - +
+ + + + {user.username.slice(0, 2).toUpperCase()} + + + + +

@@ -85,6 +122,9 @@ export default function ProfilePage() {

@{user.username}

+ {user.bio && ( +

{user.bio}

+ )}
diff --git a/frontend/src/app/(dashboard)/recent/page.tsx b/frontend/src/app/(dashboard)/recent/page.tsx index 328500b..fd5aedc 100644 --- a/frontend/src/app/(dashboard)/recent/page.tsx +++ b/frontend/src/app/(dashboard)/recent/page.tsx @@ -1,15 +1,16 @@ -"use client"; - +import type { Metadata } from "next"; import * as React from "react"; -import { ContentList } from "@/components/content-list"; -import { ContentService } from "@/services/content.service"; +import { HomeContent } from "@/components/home-content"; + +export const metadata: Metadata = { + title: "Nouveautés", + description: "Les tout derniers mèmes fraîchement débarqués sur MemeGoat.", +}; export default function RecentPage() { - const fetchFn = React.useCallback( - (params: { limit: number; offset: number }) => - ContentService.getRecent(params.limit, params.offset), - [], + return ( + Chargement des nouveautés...
}> + + ); - - return ; } diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..6a3809d --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, Save, User as UserIcon } from "lucide-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Spinner } from "@/components/ui/spinner"; +import { useAuth } from "@/providers/auth-provider"; +import { UserService } from "@/services/user.service"; + +const settingsSchema = z.object({ + displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(), + bio: z.string().max(255, "La bio est trop longue").optional(), +}); + +type SettingsFormValues = z.infer; + +export default function SettingsPage() { + const { user, isLoading, refreshUser } = useAuth(); + const [isSaving, setIsSaving] = React.useState(false); + + const form = useForm({ + resolver: zodResolver(settingsSchema), + defaultValues: { + displayName: "", + bio: "", + }, + }); + + React.useEffect(() => { + if (user) { + form.reset({ + displayName: user.displayName || "", + bio: (user as any).bio || "", + }); + } + }, [user, form]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ( +
+ + + Accès refusé + + Vous devez être connecté pour accéder aux paramètres. + + + +
+ ); + } + + const onSubmit = async (values: SettingsFormValues) => { + setIsSaving(true); + try { + await UserService.updateMe(values); + toast.success("Paramètres mis à jour !"); + await refreshUser(); + } catch (error) { + console.error(error); + toast.error("Erreur lors de la mise à jour des paramètres."); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+ +
+

Paramètres du profil

+
+ + + + Informations personnelles + + Mettez à jour vos informations publiques. Ces données seront visibles par les autres utilisateurs. + + + +
+ +
+ + Nom d'utilisateur + + + + + Le nom d'utilisateur ne peut pas être modifié. + + + + ( + + Nom d'affichage + + + + + Le nom qui sera affiché sur votre profil et vos mèmes. + + + + )} + /> + + ( + + Bio + +