UI & Feature update - Alpha #9
72
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
72
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
@@ -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<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
CategoryService.getAll()
|
||||||
|
.then(setCategories)
|
||||||
|
.catch(err => console.error(err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Catégories ({categories.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nom</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[250px]" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : categories.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center h-24">
|
||||||
|
Aucune catégorie trouvée.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
categories.map((category) => (
|
||||||
|
<TableRow key={category.id}>
|
||||||
|
<TableCell className="font-medium whitespace-nowrap">{category.name}</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">{category.slug}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{category.description || "Aucune description"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
137
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
@@ -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<Content[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Contenus ({totalCount})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Contenu</TableHead>
|
||||||
|
<TableHead>Catégorie</TableHead>
|
||||||
|
<TableHead>Auteur</TableHead>
|
||||||
|
<TableHead>Stats</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell><Skeleton className="h-10 w-[200px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[80px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : contents.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center h-24">
|
||||||
|
Aucun contenu trouvé.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
contents.map((content) => (
|
||||||
|
<TableRow key={content.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
|
||||||
|
{content.type === "image" ? (
|
||||||
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Video className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{content.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{content.type} • {content.mimeType}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{content.category.name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
@{content.author.username}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="h-3 w-3" /> {content.views}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Download className="h-3 w-3" /> {content.usageCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(content.id)}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -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<AdminStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminService
|
||||||
|
.getStats()
|
||||||
|
.then(setStats)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
setError("Impossible de charger les statistiques.");
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[50vh] flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||||
|
<p className="text-xl font-semibold">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex-1 space-y-8 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard Admin</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<Link key={card.title} href={card.href}>
|
||||||
|
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||||
|
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{card.value}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
123
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
@@ -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<User[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Utilisateurs ({totalCount})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Utilisateur</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Rôle</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Date d'inscription</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[200px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[50px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[80px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center h-24">
|
||||||
|
Aucun utilisateur trouvé.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.uuid}>
|
||||||
|
<TableCell className="font-medium whitespace-nowrap">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
<div className="text-xs text-muted-foreground">@{user.username}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.status === "active" ? "success" : "destructive"}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{format(new Date(user.createdAt), "PPP", { locale: fr })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(user.uuid)}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/app/(dashboard)/help/page.tsx
Normal file
72
frontend/src/app/(dashboard)/help/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="max-w-3xl mx-auto py-12 px-4">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="bg-primary/10 p-3 rounded-xl">
|
||||||
|
<HelpCircle className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold">Centre d'aide</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 border rounded-2xl p-6 shadow-sm mb-12">
|
||||||
|
<h2 className="text-xl font-semibold mb-6">Foire Aux Questions</h2>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<AccordionItem key={index} value={`item-${index}`}>
|
||||||
|
<AccordionTrigger className="text-left">
|
||||||
|
{faq.question}
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-muted-foreground leading-relaxed">
|
||||||
|
{faq.answer}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h2 className="text-lg font-medium">Vous ne trouvez pas de réponse ?</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-primary">contact@memegoat.local</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import * as React from "react";
|
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 { useAuth } from "@/providers/auth-provider";
|
||||||
import { ContentService } from "@/services/content.service";
|
import { ContentService } from "@/services/content.service";
|
||||||
import { FavoriteService } from "@/services/favorite.service";
|
import { FavoriteService } from "@/services/favorite.service";
|
||||||
|
import { UserService } from "@/services/user.service";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
const { user, isAuthenticated, isLoading, logout, refreshUser } = useAuth();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const tab = searchParams.get("tab") || "memes";
|
const tab = searchParams.get("tab") || "memes";
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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(
|
const fetchMyMemes = React.useCallback(
|
||||||
(params: { limit: number; offset: number }) =>
|
(params: { limit: number; offset: number }) =>
|
||||||
@@ -72,12 +93,28 @@ export default function ProfilePage() {
|
|||||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
|
||||||
<div className="flex flex-col md:flex-row items-center gap-8">
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||||
<Avatar className="h-32 w-32 border-4 border-primary/10">
|
<div className="relative group">
|
||||||
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
<Avatar className="h-32 w-32 border-4 border-primary/10">
|
||||||
<AvatarFallback className="text-4xl">
|
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
||||||
{user.username.slice(0, 2).toUpperCase()}
|
<AvatarFallback className="text-4xl">
|
||||||
</AvatarFallback>
|
{user.username.slice(0, 2).toUpperCase()}
|
||||||
</Avatar>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Camera className="h-8 w-8" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex-1 text-center md:text-left space-y-4">
|
<div className="flex-1 text-center md:text-left space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
@@ -85,6 +122,9 @@ export default function ProfilePage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">@{user.username}</p>
|
<p className="text-muted-foreground">@{user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{user.bio && (
|
||||||
|
<p className="max-w-md text-sm leading-relaxed">{user.bio}</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ContentList } from "@/components/content-list";
|
import { HomeContent } from "@/components/home-content";
|
||||||
import { ContentService } from "@/services/content.service";
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Nouveautés",
|
||||||
|
description: "Les tout derniers mèmes fraîchement débarqués sur MemeGoat.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RecentPage() {
|
export default function RecentPage() {
|
||||||
const fetchFn = React.useCallback(
|
return (
|
||||||
(params: { limit: number; offset: number }) =>
|
<React.Suspense fallback={<div className="p-8 text-center">Chargement des nouveautés...</div>}>
|
||||||
ContentService.getRecent(params.limit, params.offset),
|
<HomeContent defaultSort="recent" />
|
||||||
[],
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
185
frontend/src/app/(dashboard)/settings/page.tsx
Normal file
185
frontend/src/app/(dashboard)/settings/page.tsx
Normal file
@@ -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<typeof settingsSchema>;
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user, isLoading, refreshUser } = useAuth();
|
||||||
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
|
||||||
|
const form = useForm<SettingsFormValues>({
|
||||||
|
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 (
|
||||||
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 px-4 text-center">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Accès refusé</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vous devez être connecté pour accéder aux paramètres.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="max-w-2xl mx-auto py-12 px-4">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="bg-primary/10 p-3 rounded-xl">
|
||||||
|
<UserIcon className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold">Paramètres du profil</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Informations personnelles</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Mettez à jour vos informations publiques. Ces données seront visibles par les autres utilisateurs.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nom d'utilisateur</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input value={user.username} disabled className="bg-zinc-50 dark:bg-zinc-900" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Le nom d'utilisateur ne peut pas être modifié.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="displayName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nom d'affichage</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Votre nom" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Le nom qui sera affiché sur votre profil et vos mèmes.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Racontez-nous quelque chose sur vous..."
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Une courte description de vous (max 255 caractères).
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Enregistrement...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Enregistrer les modifications
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ContentList } from "@/components/content-list";
|
import { HomeContent } from "@/components/home-content";
|
||||||
import { ContentService } from "@/services/content.service";
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Tendances",
|
||||||
|
description: "Découvrez les mèmes les plus populaires du moment sur MemeGoat.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function TrendsPage() {
|
export default function TrendsPage() {
|
||||||
const fetchFn = React.useCallback(
|
return (
|
||||||
(params: { limit: number; offset: number }) =>
|
<React.Suspense fallback={<div className="p-8 text-center">Chargement des tendances...</div>}>
|
||||||
ContentService.getTrends(params.limit, params.offset),
|
<HomeContent defaultSort="trend" />
|
||||||
[],
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ContentList fetchFn={fetchFn} title="Top Tendances" />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
98
frontend/src/app/(dashboard)/user/[username]/page.tsx
Normal file
98
frontend/src/app/(dashboard)/user/[username]/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Calendar, User as UserIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ContentList } from "@/components/content-list";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
import { UserService } from "@/services/user.service";
|
||||||
|
import type { User } from "@/types/user";
|
||||||
|
|
||||||
|
export default function PublicProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ username: string }>;
|
||||||
|
}) {
|
||||||
|
const { username } = React.use(params);
|
||||||
|
const [user, setUser] = React.useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
UserService.getProfile(username)
|
||||||
|
.then(setUser)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
const fetchUserMemes = React.useCallback(
|
||||||
|
(params: { limit: number; offset: number }) =>
|
||||||
|
ContentService.getExplore({ ...params, author: username }),
|
||||||
|
[username],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-12 px-4 text-center">
|
||||||
|
<div className="bg-primary/10 p-4 rounded-full w-fit mx-auto mb-4">
|
||||||
|
<UserIcon className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Utilisateur non trouvé</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Le membre @{username} n'existe pas ou a quitté le troupeau.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||||
|
<Avatar className="h-32 w-32 border-4 border-primary/10">
|
||||||
|
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
||||||
|
<AvatarFallback className="text-4xl">
|
||||||
|
{user.username.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 text-center md:text-left space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
{user.bio && (
|
||||||
|
<p className="max-w-md text-sm leading-relaxed mx-auto md:mx-0">
|
||||||
|
{user.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Membre depuis{" "}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<h2 className="text-xl font-bold border-b pb-4">Ses mèmes</h2>
|
||||||
|
<ContentList fetchFn={fetchUserMemes} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user