feat(admin): add edit dialogs for users, contents, and categories

- Implemented edit functionality for users, contents, and categories, including modals for updating records.
- Enhanced table actions with edit buttons alongside delete.
- Improved user, content, and category fetching with `useCallback` to optimize re-renders.
- Added skeleton loaders and UI updates for better user experience.
This commit is contained in:
Mathis HERRIOT
2026-01-21 13:19:29 +01:00
parent 3a5550d6eb
commit 9b714716f6
3 changed files with 181 additions and 31 deletions

View File

@@ -1,6 +1,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { Edit, Plus, Trash2 } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, Table,
@@ -12,24 +15,57 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content"; import type { Category } from "@/types/content";
import { CategoryDialog } from "./category-dialog";
export default function AdminCategoriesPage() { export default function AdminCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
null,
);
useEffect(() => { const fetchCategories = useCallback(() => {
setLoading(true);
CategoryService.getAll() CategoryService.getAll()
.then(setCategories) .then(setCategories)
.catch((err) => console.error(err)) .catch((err) => console.error(err))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer cette catégorie ?")) return;
try {
await CategoryService.remove(id);
setCategories(categories.filter((c) => c.id !== id));
} catch (error) {
console.error(error);
}
};
const handleEdit = (category: Category) => {
setSelectedCategory(category);
setDialogOpen(true);
};
const handleCreate = () => {
setSelectedCategory(null);
setDialogOpen(true);
};
return ( return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8"> <div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">
Catégories ({categories.length}) Catégories ({categories.length})
</h2> </h2>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" /> Ajouter une catégorie
</Button>
</div> </div>
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<Table> <Table>
@@ -38,6 +74,7 @@ export default function AdminCategoriesPage() {
<TableHead>Nom</TableHead> <TableHead>Nom</TableHead>
<TableHead>Slug</TableHead> <TableHead>Slug</TableHead>
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -54,11 +91,14 @@ export default function AdminCategoriesPage() {
<TableCell> <TableCell>
<Skeleton className="h-4 w-[250px]" /> <Skeleton className="h-4 w-[250px]" />
</TableCell> </TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow> </TableRow>
)) ))
) : categories.length === 0 ? ( ) : categories.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center h-24"> <TableCell colSpan={4} className="text-center h-24">
Aucune catégorie trouvée. Aucune catégorie trouvée.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -66,18 +106,55 @@ export default function AdminCategoriesPage() {
categories.map((category) => ( categories.map((category) => (
<TableRow key={category.id}> <TableRow key={category.id}>
<TableCell className="font-medium whitespace-nowrap"> <TableCell className="font-medium whitespace-nowrap">
{category.name} <div className="flex items-center gap-2">
{category.iconUrl && (
<div className="relative h-6 w-6">
<Image
src={category.iconUrl}
alt=""
fill
className="rounded object-cover"
/>
</div>
)}
{category.name}
</div>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap">{category.slug}</TableCell> <TableCell className="whitespace-nowrap">{category.slug}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{category.description || "Aucune description"} {category.description || "Aucune description"}
</TableCell> </TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(category)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(category.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<CategoryDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
category={selectedCategory}
onSuccess={fetchCategories}
/>
</div> </div>
); );
} }

View File

@@ -2,8 +2,15 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react"; import {
import { useEffect, useState } from "react"; Download,
Edit,
Eye,
Image as ImageIcon,
Trash2,
Video,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -17,13 +24,17 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
import { ContentEditDialog } from "./content-edit-dialog";
export default function AdminContentsPage() { export default function AdminContentsPage() {
const [contents, setContents] = useState<Content[]>([]); const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [selectedContent, setSelectedContent] = useState<Content | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => { const fetchContents = useCallback(() => {
setLoading(true);
ContentService.getExplore({ limit: 20 }) ContentService.getExplore({ limit: 20 })
.then((res) => { .then((res) => {
setContents(res.data); setContents(res.data);
@@ -33,6 +44,10 @@ export default function AdminContentsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
fetchContents();
}, [fetchContents]);
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return; if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
@@ -45,6 +60,11 @@ export default function AdminContentsPage() {
} }
}; };
const handleEdit = (content: Content) => {
setSelectedContent(content);
setDialogOpen(true);
};
return ( return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8"> <div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -61,7 +81,7 @@ export default function AdminContentsPage() {
<TableHead>Auteur</TableHead> <TableHead>Auteur</TableHead>
<TableHead>Stats</TableHead> <TableHead>Stats</TableHead>
<TableHead>Date</TableHead> <TableHead>Date</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -84,11 +104,14 @@ export default function AdminContentsPage() {
<TableCell> <TableCell>
<Skeleton className="h-4 w-[100px]" /> <Skeleton className="h-4 w-[100px]" />
</TableCell> </TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow> </TableRow>
)) ))
) : contents.length === 0 ? ( ) : contents.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center h-24"> <TableCell colSpan={6} className="text-center h-24">
Aucun contenu trouvé. Aucun contenu trouvé.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -132,14 +155,23 @@ export default function AdminContentsPage() {
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })} {format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <div className="flex items-center gap-2">
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => handleDelete(content.id)} size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => handleEdit(content)}
> >
<Trash2 className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<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>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -147,6 +179,12 @@ export default function AdminContentsPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<ContentEditDialog
content={selectedContent}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSuccess={fetchContents}
/>
</div> </div>
); );
} }

View File

@@ -2,8 +2,8 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Trash2 } from "lucide-react"; import { Edit, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -17,13 +17,17 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
import { UserEditDialog } from "./user-edit-dialog";
export default function AdminUsersPage() { export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => { const fetchUsers = useCallback(() => {
setLoading(true);
UserService.getUsersAdmin() UserService.getUsersAdmin()
.then((res) => { .then((res) => {
setUsers(res.data); setUsers(res.data);
@@ -35,6 +39,10 @@ export default function AdminUsersPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleDelete = async (uuid: string) => { const handleDelete = async (uuid: string) => {
if ( if (
!confirm( !confirm(
@@ -52,6 +60,11 @@ export default function AdminUsersPage() {
} }
}; };
const handleEdit = (user: User) => {
setSelectedUser(user);
setDialogOpen(true);
};
return ( return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8"> <div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -68,7 +81,7 @@ export default function AdminUsersPage() {
<TableHead>Rôle</TableHead> <TableHead>Rôle</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Date d'inscription</TableHead> <TableHead>Date d'inscription</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -91,11 +104,14 @@ export default function AdminUsersPage() {
<TableCell> <TableCell>
<Skeleton className="h-4 w-[100px]" /> <Skeleton className="h-4 w-[100px]" />
</TableCell> </TableCell>
<TableCell>
<Skeleton className="h-8 w-8 rounded-full" />
</TableCell>
</TableRow> </TableRow>
)) ))
) : users.length === 0 ? ( ) : users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center h-24"> <TableCell colSpan={6} className="text-center h-24">
Aucun utilisateur trouvé. Aucun utilisateur trouvé.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -113,7 +129,15 @@ export default function AdminUsersPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={user.status === "active" ? "success" : "destructive"}> <Badge
variant={
user.status === "active"
? "success"
: user.status === "suspended"
? "destructive"
: "secondary"
}
>
{user.status} {user.status}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -121,14 +145,19 @@ export default function AdminUsersPage() {
{format(new Date(user.createdAt), "PPP", { locale: fr })} {format(new Date(user.createdAt), "PPP", { locale: fr })}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <div className="flex items-center gap-2">
variant="ghost" <Button variant="ghost" size="icon" onClick={() => handleEdit(user)}>
size="icon" <Edit className="h-4 w-4" />
onClick={() => handleDelete(user.uuid)} </Button>
className="text-destructive hover:text-destructive hover:bg-destructive/10" <Button
> variant="ghost"
<Trash2 className="h-4 w-4" /> size="icon"
</Button> onClick={() => handleDelete(user.uuid)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -136,6 +165,12 @@ export default function AdminUsersPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<UserEditDialog
user={selectedUser}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSuccess={fetchUsers}
/>
</div> </div>
); );
} }