Refactor modal implementation across admin components: Replace Card components with a reusable Modal component in ChallengeManagement, EventManagement, and UserManagement, enhancing UI consistency and maintainability. Update Modal to use React portals for improved rendering.

This commit is contained in:
Julien Froidefond
2025-12-16 16:50:06 +01:00
parent 16e4b63ffd
commit 79c21955e0
4 changed files with 720 additions and 662 deletions

View File

@@ -9,7 +9,7 @@ import {
adminCancelChallenge, adminCancelChallenge,
reactivateChallenge, reactivateChallenge,
} from "@/actions/admin/challenges"; } from "@/actions/admin/challenges";
import { Button, Card, Input, Textarea, Alert } from "@/components/ui"; import { Button, Card, Input, Textarea, Alert, Modal, CloseButton } from "@/components/ui";
import { Avatar } from "@/components/ui"; import { Avatar } from "@/components/ui";
interface Challenge { interface Challenge {
@@ -417,23 +417,29 @@ export default function ChallengeManagement() {
{/* Modal de validation */} {/* Modal de validation */}
{selectedChallenge && ( {selectedChallenge && (
<div <Modal
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" isOpen={!!selectedChallenge}
onClose={() => {
setSelectedChallenge(null);
setWinnerId("");
setAdminComment("");
}}
size="lg"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-pixel-gold">
Désigner le gagnant
</h2>
<CloseButton
onClick={() => { onClick={() => {
setSelectedChallenge(null); setSelectedChallenge(null);
setWinnerId(""); setWinnerId("");
setAdminComment(""); setAdminComment("");
}} }}
> size="lg"
<Card />
variant="dark" </div>
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
Désigner le gagnant
</h2>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-gray-300 mb-2"> <h3 className="text-lg font-bold text-gray-300 mb-2">
@@ -545,30 +551,36 @@ export default function ChallengeManagement() {
</Button> </Button>
</div> </div>
</div> </div>
</Card> </Modal>
</div>
)} )}
{/* Modal d'édition */} {/* Modal d'édition */}
{editingChallenge && ( {editingChallenge && (
<div <Modal
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" isOpen={!!editingChallenge}
onClose={() => {
setEditingChallenge(null);
setEditTitle("");
setEditDescription("");
setEditPointsReward(0);
}}
size="lg"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-pixel-gold">
Modifier le défi
</h2>
<CloseButton
onClick={() => { onClick={() => {
setEditingChallenge(null); setEditingChallenge(null);
setEditTitle(""); setEditTitle("");
setEditDescription(""); setEditDescription("");
setEditPointsReward(0); setEditPointsReward(0);
}} }}
> size="lg"
<Card />
variant="dark" </div>
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
Modifier le défi
</h2>
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
@@ -632,8 +644,7 @@ export default function ChallengeManagement() {
</div> </div>
</div> </div>
</div> </div>
</Card> </Modal>
</div>
)} )}
</div> </div>
); );

View File

@@ -3,7 +3,15 @@
import { useState, useEffect, useTransition } from "react"; import { useState, useEffect, useTransition } from "react";
import { calculateEventStatus } from "@/lib/eventStatus"; import { calculateEventStatus } from "@/lib/eventStatus";
import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events"; import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events";
import { Input, Textarea, Button, Card, Badge } from "@/components/ui"; import {
Input,
Textarea,
Button,
Card,
Badge,
Modal,
CloseButton,
} from "@/components/ui";
interface Event { interface Event {
id: string; id: string;
@@ -221,11 +229,20 @@ export default function EventManagement() {
)} )}
</div> </div>
{/* Modal de création/édition */}
{(isCreating || editingEvent) && ( {(isCreating || editingEvent) && (
<Card variant="default" className="p-3 sm:p-4 mb-4"> <Modal
<h4 className="text-pixel-gold font-bold mb-4 text-base sm:text-lg break-words"> isOpen={isCreating || !!editingEvent}
onClose={handleCancel}
size="lg"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
{isCreating ? "Créer un événement" : "Modifier l'événement"} {isCreating ? "Créer un événement" : "Modifier l'événement"}
</h4> </h4>
<CloseButton onClick={handleCancel} size="lg" />
</div>
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
type="date" type="date"
@@ -330,7 +347,8 @@ export default function EventManagement() {
</Button> </Button>
</div> </div>
</div> </div>
</Card> </div>
</Modal>
)} )}
{events.length === 0 ? ( {events.length === 0 ? (

View File

@@ -1,7 +1,14 @@
"use client"; "use client";
import { useState, useEffect, useTransition } from "react"; import { useState, useEffect, useTransition } from "react";
import { Avatar, Input, Button, Card } from "@/components/ui"; import {
Avatar,
Input,
Button,
Card,
Modal,
CloseButton,
} from "@/components/ui";
import { updateUser, deleteUser } from "@/actions/admin/users"; import { updateUser, deleteUser } from "@/actions/admin/users";
interface User { interface User {
@@ -159,6 +166,25 @@ export default function UserManagement() {
return num.toLocaleString("en-US"); return num.toLocaleString("en-US");
}; };
// Trouver l'utilisateur en cours d'édition pour les previews
const currentEditingUserData = editingUser
? users.find((u) => u.id === editingUser.userId)
: null;
const previewHp =
currentEditingUserData && editingUser
? Math.max(
0,
Math.min(
currentEditingUserData.maxHp,
currentEditingUserData.hp + editingUser.hpDelta
)
)
: 0;
const previewXp =
currentEditingUserData && editingUser
? Math.max(0, currentEditingUserData.xp + editingUser.xpDelta)
: 0;
if (loading) { if (loading) {
return <div className="text-center text-gray-400 py-8">Chargement...</div>; return <div className="text-center text-gray-400 py-8">Chargement...</div>;
} }
@@ -171,26 +197,14 @@ export default function UserManagement() {
</div> </div>
) : ( ) : (
users.map((user) => { users.map((user) => {
const isEditing = editingUser?.userId === user.id;
const previewHp = isEditing
? Math.max(0, Math.min(user.maxHp, user.hp + editingUser.hpDelta))
: user.hp;
const previewXp = isEditing
? Math.max(0, user.xp + editingUser.xpDelta)
: user.xp;
const displayAvatar = isEditing ? editingUser.avatar : user.avatar;
const displayUsername = isEditing
? editingUser.username || user.username
: user.username;
return ( return (
<Card key={user.id} variant="default" className="p-3 sm:p-4"> <Card key={user.id} variant="default" className="p-3 sm:p-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-2"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-2">
<div className="flex gap-2 sm:gap-3 items-center flex-1 min-w-0"> <div className="flex gap-2 sm:gap-3 items-center flex-1 min-w-0">
{/* Avatar */} {/* Avatar */}
<Avatar <Avatar
src={displayAvatar} src={user.avatar}
username={displayUsername} username={user.username}
size="sm" size="sm"
className="flex-shrink-0" className="flex-shrink-0"
borderClassName="border-2 border-pixel-gold/50" borderClassName="border-2 border-pixel-gold/50"
@@ -198,7 +212,7 @@ export default function UserManagement() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap"> <div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
<h3 className="text-pixel-gold font-bold text-sm sm:text-base break-words"> <h3 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
{displayUsername} {user.username}
</h3> </h3>
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap"> <span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
Niveau {user.level} Niveau {user.level}
@@ -221,7 +235,6 @@ export default function UserManagement() {
</p> </p>
</div> </div>
</div> </div>
{!isEditing && (
<div className="flex gap-2 flex-shrink-0 sm:ml-2"> <div className="flex gap-2 flex-shrink-0 sm:ml-2">
<button <button
onClick={() => handleEdit(user)} onClick={() => handleEdit(user)}
@@ -239,10 +252,64 @@ export default function UserManagement() {
: "Supprimer"} : "Supprimer"}
</button> </button>
</div> </div>
)}
</div> </div>
{isEditing ? ( {/* Affichage des stats */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 text-[10px] sm:text-xs">
<div className="flex-1">
<div className="flex justify-between items-center mb-0.5">
<span className="text-gray-400">HP</span>
<span className="text-gray-400">
{user.hp}/{user.maxHp}
</span>
</div>
<div className="h-1.5 bg-black/60 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-red-600 to-green-500"
style={{
width: `${Math.min(
100,
(user.hp / user.maxHp) * 100
)}%`,
}}
/>
</div>
</div>
<div className="flex-1">
<div className="flex justify-between items-center mb-0.5">
<span className="text-gray-400">XP</span>
<span className="text-gray-400">
{formatNumber(user.xp)}/{formatNumber(user.maxXp)}
</span>
</div>
<div className="h-1.5 bg-black/60 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-600 to-purple-500"
style={{
width: `${Math.min(
100,
(user.xp / user.maxXp) * 100
)}%`,
}}
/>
</div>
</div>
</div>
</Card>
);
})
)}
{/* Modal d'édition */}
{editingUser && currentEditingUserData && (
<Modal isOpen={!!editingUser} onClose={handleCancel} size="lg">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Modifier l&apos;utilisateur
</h4>
<CloseButton onClick={handleCancel} size="lg" />
</div>
<div className="space-y-4"> <div className="space-y-4">
{/* Username Section */} {/* Username Section */}
<Input <Input
@@ -269,15 +336,15 @@ export default function UserManagement() {
<div className="relative"> <div className="relative">
<Avatar <Avatar
src={editingUser.avatar} src={editingUser.avatar}
username={editingUser.username || user.username} username={
editingUser.username || currentEditingUserData.username
}
size="lg" size="lg"
borderClassName="border-2 border-pixel-gold/50" borderClassName="border-2 border-pixel-gold/50"
/> />
{uploadingAvatar === user.id && ( {uploadingAvatar === editingUser.userId && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full"> <div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
<div className="text-pixel-gold text-xs"> <div className="text-pixel-gold text-xs">Upload...</div>
Upload...
</div>
</div> </div>
)} )}
</div> </div>
@@ -330,7 +397,7 @@ export default function UserManagement() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setUploadingAvatar(user.id); setUploadingAvatar(editingUser.userId);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
@@ -363,16 +430,16 @@ export default function UserManagement() {
} }
}} }}
className="hidden" className="hidden"
id={`avatar-upload-${user.id}`} id={`avatar-upload-${editingUser.userId}`}
/> />
<label htmlFor={`avatar-upload-${user.id}`}> <label htmlFor={`avatar-upload-${editingUser.userId}`}>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
as="span" as="span"
className="cursor-pointer" className="cursor-pointer"
> >
{uploadingAvatar === user.id {uploadingAvatar === editingUser.userId
? "Upload en cours..." ? "Upload en cours..."
: "Upload un avatar custom"} : "Upload un avatar custom"}
</Button> </Button>
@@ -387,7 +454,7 @@ export default function UserManagement() {
Points de Vie (HP) Points de Vie (HP)
</label> </label>
<span className="text-[10px] sm:text-xs text-gray-400"> <span className="text-[10px] sm:text-xs text-gray-400">
{previewHp} / {user.maxHp} {previewHp} / {currentEditingUserData.maxHp}
</span> </span>
</div> </div>
<div className="flex gap-1 sm:gap-2 flex-wrap"> <div className="flex gap-1 sm:gap-2 flex-wrap">
@@ -453,7 +520,7 @@ export default function UserManagement() {
style={{ style={{
width: `${Math.min( width: `${Math.min(
100, 100,
(previewHp / user.maxHp) * 100 (previewHp / currentEditingUserData.maxHp) * 100
)}%`, )}%`,
}} }}
/> />
@@ -467,7 +534,8 @@ export default function UserManagement() {
Expérience (XP) Expérience (XP)
</label> </label>
<span className="text-[10px] sm:text-xs text-gray-400"> <span className="text-[10px] sm:text-xs text-gray-400">
{formatNumber(previewXp)} / {formatNumber(user.maxXp)} {formatNumber(previewXp)} /{" "}
{formatNumber(currentEditingUserData.maxXp)}
</span> </span>
</div> </div>
<div className="flex gap-1 sm:gap-2 flex-wrap"> <div className="flex gap-1 sm:gap-2 flex-wrap">
@@ -533,7 +601,7 @@ export default function UserManagement() {
style={{ style={{
width: `${Math.min( width: `${Math.min(
100, 100,
(previewXp / user.maxXp) * 100 (previewXp / currentEditingUserData.maxXp) * 100
)}%`, )}%`,
}} }}
/> />
@@ -695,60 +763,13 @@ export default function UserManagement() {
> >
{saving ? "Enregistrement..." : "Enregistrer"} {saving ? "Enregistrement..." : "Enregistrer"}
</Button> </Button>
<Button <Button onClick={handleCancel} variant="secondary" size="md">
onClick={handleCancel}
variant="secondary"
size="md"
>
Annuler Annuler
</Button> </Button>
</div> </div>
</div> </div>
) : (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 text-[10px] sm:text-xs">
<div className="flex-1">
<div className="flex justify-between items-center mb-0.5">
<span className="text-gray-400">HP</span>
<span className="text-gray-400">
{user.hp}/{user.maxHp}
</span>
</div> </div>
<div className="h-1.5 bg-black/60 rounded-full overflow-hidden"> </Modal>
<div
className="h-full bg-gradient-to-r from-red-600 to-green-500"
style={{
width: `${Math.min(
100,
(user.hp / user.maxHp) * 100
)}%`,
}}
/>
</div>
</div>
<div className="flex-1">
<div className="flex justify-between items-center mb-0.5">
<span className="text-gray-400">XP</span>
<span className="text-gray-400">
{formatNumber(user.xp)}/{formatNumber(user.maxXp)}
</span>
</div>
<div className="h-1.5 bg-black/60 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-600 to-purple-500"
style={{
width: `${Math.min(
100,
(user.xp / user.maxXp) * 100
)}%`,
}}
/>
</div>
</div>
</div>
)}
</Card>
);
})
)} )}
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { ReactNode, useEffect } from "react"; import { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
@@ -37,7 +38,7 @@ export default function Modal({
if (!isOpen) return null; if (!isOpen) return null;
return ( const modalContent = (
<div <div
className="fixed inset-0 z-[200] flex items-center justify-center p-4 backdrop-blur-sm" className="fixed inset-0 z-[200] flex items-center justify-center p-4 backdrop-blur-sm"
style={{ style={{
@@ -59,4 +60,11 @@ export default function Modal({
</div> </div>
</div> </div>
); );
// Utiliser un portal pour rendre le modal directement dans le body
if (typeof window !== "undefined") {
return createPortal(modalContent, document.body);
}
return null;
} }