Files
got-gaming/components/admin/UserManagement.tsx

777 lines
29 KiB
TypeScript

"use client";
import { useState, useEffect, useTransition } from "react";
import {
Avatar,
Input,
Button,
Card,
Modal,
CloseButton,
} from "@/components/ui";
import { updateUser, deleteUser } from "@/actions/admin/users";
interface User {
id: string;
username: string;
email: string;
role: string;
score: number;
level: number;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
avatar: string | null;
createdAt: string;
}
interface EditingUser {
userId: string;
username: string | null;
avatar: string | null;
hpDelta: number;
xpDelta: number;
score: number | null;
level: number | null;
role: string | null;
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [uploadingAvatar, setUploadingAvatar] = useState<string | null>(null);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await fetch("/api/admin/users");
if (response.ok) {
const data = await response.json();
setUsers(data);
}
} catch (error) {
console.error("Error fetching users:", error);
} finally {
setLoading(false);
}
};
const handleEdit = (user: User) => {
setEditingUser({
userId: user.id,
username: user.username,
avatar: user.avatar,
hpDelta: 0,
xpDelta: 0,
score: user.score,
level: user.level,
role: user.role,
});
};
const handleSave = async () => {
if (!editingUser) return;
setSaving(true);
startTransition(async () => {
try {
const body: {
username?: string;
avatar?: string | null;
hpDelta?: number;
xpDelta?: number;
score?: number;
level?: number;
role?: string;
} = {};
if (editingUser.username !== null) {
body.username = editingUser.username;
}
if (editingUser.avatar !== undefined) {
body.avatar = editingUser.avatar;
}
if (editingUser.hpDelta !== 0) {
body.hpDelta = editingUser.hpDelta;
}
if (editingUser.xpDelta !== 0) {
body.xpDelta = editingUser.xpDelta;
}
if (editingUser.score !== null) {
body.score = editingUser.score;
}
if (editingUser.level !== null) {
body.level = editingUser.level;
}
if (editingUser.role !== null) {
body.role = editingUser.role;
}
const result = await updateUser(editingUser.userId, body);
if (result.success) {
await fetchUsers();
setEditingUser(null);
} else {
alert(result.error || "Erreur lors de la mise à jour");
}
} catch (error) {
console.error("Error updating user:", error);
alert("Erreur lors de la mise à jour");
} finally {
setSaving(false);
}
});
};
const handleCancel = () => {
setEditingUser(null);
};
const handleDelete = async (userId: string) => {
if (
!confirm(
"Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible."
)
) {
return;
}
setDeletingUserId(userId);
try {
const result = await deleteUser(userId);
if (result.success) {
await fetchUsers();
} else {
alert(result.error || "Erreur lors de la suppression");
}
} catch (error) {
console.error("Error deleting user:", error);
alert("Erreur lors de la suppression");
} finally {
setDeletingUserId(null);
}
};
const formatNumber = (num: number) => {
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) {
return <div className="text-center text-gray-400 py-8">Chargement...</div>;
}
return (
<div className="space-y-4">
{users.length === 0 ? (
<div className="text-center text-gray-400 py-8">
Aucun utilisateur trouvé
</div>
) : (
users.map((user) => {
return (
<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 gap-2 sm:gap-3 items-center flex-1 min-w-0">
{/* Avatar */}
<Avatar
src={user.avatar}
username={user.username}
size="sm"
className="flex-shrink-0"
borderClassName="border-2 border-pixel-gold/50"
/>
<div className="flex-1 min-w-0">
<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">
{user.username}
</h3>
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
Niveau {user.level}
</span>
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
Score: {formatNumber(user.score)}
</span>
<span
className={`text-[10px] sm:text-xs whitespace-nowrap ${
user.role === "ADMIN"
? "text-pixel-gold"
: "text-gray-500"
}`}
>
{user.role}
</span>
</div>
<p className="text-gray-400 text-[10px] sm:text-xs truncate">
{user.email}
</p>
</div>
</div>
<div className="flex gap-2 flex-shrink-0 sm:ml-2">
<button
onClick={() => handleEdit(user)}
className="px-2 sm:px-3 py-1.5 border border-pixel-gold/50 bg-black/60 text-white uppercase text-[10px] sm:text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition whitespace-nowrap"
>
Modifier
</button>
<button
onClick={() => handleDelete(user.id)}
disabled={deletingUserId === user.id}
className="px-2 sm:px-3 py-1.5 border border-red-500/50 bg-red-900/20 text-red-400 uppercase text-[10px] sm:text-xs tracking-widest rounded hover:bg-red-900/30 transition disabled:opacity-50 whitespace-nowrap"
>
{deletingUserId === user.id
? "Suppression..."
: "Supprimer"}
</button>
</div>
</div>
{/* 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">
{/* Username Section */}
<Input
type="text"
label="Nom d'utilisateur"
value={editingUser.username || ""}
onChange={(e) =>
setEditingUser({
...editingUser,
username: e.target.value,
})
}
placeholder="Nom d'utilisateur"
className="text-xs sm:text-sm px-2 sm:px-3 py-1"
/>
{/* Avatar Section */}
<div className="flex flex-col items-center gap-3">
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
Avatar
</label>
{/* Preview */}
<div className="relative">
<Avatar
src={editingUser.avatar}
username={
editingUser.username || currentEditingUserData.username
}
size="lg"
borderClassName="border-2 border-pixel-gold/50"
/>
{uploadingAvatar === editingUser.userId && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
<div className="text-pixel-gold text-xs">Upload...</div>
</div>
)}
</div>
{/* Avatars par défaut */}
<div className="flex flex-col items-center gap-2 w-full">
<label className="block text-pixel-gold text-[10px] sm:text-xs uppercase tracking-widest">
Avatars par défaut
</label>
<div className="flex flex-wrap gap-2 justify-center">
{[
"/avatar-1.jpg",
"/avatar-2.jpg",
"/avatar-3.jpg",
"/avatar-4.jpg",
"/avatar-5.jpg",
"/avatar-6.jpg",
].map((defaultAvatar) => (
<button
key={defaultAvatar}
type="button"
onClick={() =>
setEditingUser({
...editingUser,
avatar: defaultAvatar,
})
}
className={`w-12 h-12 sm:w-14 sm:h-14 rounded-full border-2 overflow-hidden transition ${
editingUser.avatar === defaultAvatar
? "border-pixel-gold scale-110"
: "border-pixel-gold/30 hover:border-pixel-gold/50"
}`}
>
<img
src={defaultAvatar}
alt="Avatar par défaut"
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
{/* Custom Upload */}
<div>
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(editingUser.userId);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(
"/api/admin/avatars/upload",
{
method: "POST",
body: formData,
}
);
if (response.ok) {
const data = await response.json();
setEditingUser({
...editingUser,
avatar: data.url,
});
} else {
alert("Erreur lors de l'upload de l'image");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("Erreur lors de l'upload de l'image");
} finally {
setUploadingAvatar(null);
if (e.target) {
e.target.value = "";
}
}
}}
className="hidden"
id={`avatar-upload-${editingUser.userId}`}
/>
<label htmlFor={`avatar-upload-${editingUser.userId}`}>
<Button
variant="primary"
size="sm"
as="span"
className="cursor-pointer"
>
{uploadingAvatar === editingUser.userId
? "Upload en cours..."
: "Upload un avatar custom"}
</Button>
</label>
</div>
</div>
{/* HP Section */}
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-xs sm:text-sm text-gray-300">
Points de Vie (HP)
</label>
<span className="text-[10px] sm:text-xs text-gray-400">
{previewHp} / {currentEditingUserData.maxHp}
</span>
</div>
<div className="flex gap-1 sm:gap-2 flex-wrap">
<button
onClick={() =>
setEditingUser({
...editingUser,
hpDelta: editingUser.hpDelta - 100,
})
}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
>
-100
</button>
<button
onClick={() =>
setEditingUser({
...editingUser,
hpDelta: editingUser.hpDelta - 10,
})
}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
>
-10
</button>
<input
type="number"
value={editingUser.hpDelta || 0}
onChange={(e) =>
setEditingUser({
...editingUser,
hpDelta: parseInt(e.target.value) || 0,
})
}
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
/>
<button
onClick={() =>
setEditingUser({
...editingUser,
hpDelta: editingUser.hpDelta + 10,
})
}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
>
+10
</button>
<button
onClick={() =>
setEditingUser({
...editingUser,
hpDelta: editingUser.hpDelta + 100,
})
}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
>
+100
</button>
</div>
<div className="mt-2 h-2 bg-black/60 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-red-600 to-green-500 transition-all duration-300"
style={{
width: `${Math.min(
100,
(previewHp / currentEditingUserData.maxHp) * 100
)}%`,
}}
/>
</div>
</div>
{/* XP Section */}
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-xs sm:text-sm text-gray-300">
Expérience (XP)
</label>
<span className="text-[10px] sm:text-xs text-gray-400">
{formatNumber(previewXp)} /{" "}
{formatNumber(currentEditingUserData.maxXp)}
</span>
</div>
<div className="flex gap-1 sm:gap-2 flex-wrap">
<button
onClick={() =>
setEditingUser({
...editingUser,
xpDelta: editingUser.xpDelta - 1000,
})
}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
>
-1000
</button>
<button
onClick={() =>
setEditingUser({
...editingUser,
xpDelta: editingUser.xpDelta - 100,
})
}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
>
-100
</button>
<input
type="number"
value={editingUser.xpDelta || 0}
onChange={(e) =>
setEditingUser({
...editingUser,
xpDelta: parseInt(e.target.value) || 0,
})
}
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
/>
<button
onClick={() =>
setEditingUser({
...editingUser,
xpDelta: editingUser.xpDelta + 100,
})
}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
>
+100
</button>
<button
onClick={() =>
setEditingUser({
...editingUser,
xpDelta: editingUser.xpDelta + 1000,
})
}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
>
+1000
</button>
</div>
<div className="mt-2 h-2 bg-black/60 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-600 to-purple-500 transition-all duration-300"
style={{
width: `${Math.min(
100,
(previewXp / currentEditingUserData.maxXp) * 100
)}%`,
}}
/>
</div>
</div>
{/* Score Section */}
<div>
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
Score
</label>
<div className="flex gap-1 sm:gap-2 flex-wrap">
<button
onClick={() =>
setEditingUser({
...editingUser,
score: (editingUser.score || 0) - 1000,
})
}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
>
-1000
</button>
<button
onClick={() =>
setEditingUser({
...editingUser,
score: (editingUser.score || 0) - 100,
})
}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
>
-100
</button>
<input
type="number"
value={editingUser.score ?? 0}
onChange={(e) =>
setEditingUser({
...editingUser,
score: parseInt(e.target.value) || 0,
})
}
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
/>
<button
onClick={() =>
setEditingUser({
...editingUser,
score: (editingUser.score || 0) + 100,
})
}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
>
+100
</button>
<button
onClick={() =>
setEditingUser({
...editingUser,
score: (editingUser.score || 0) + 1000,
})
}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
>
+1000
</button>
</div>
</div>
{/* Level Section */}
<div>
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
Niveau
</label>
<div className="flex gap-1 sm:gap-2">
<button
onClick={() =>
setEditingUser({
...editingUser,
level: Math.max(1, (editingUser.level || 1) - 1),
})
}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
>
-1
</button>
<input
type="number"
min="1"
value={editingUser.level ?? 1}
onChange={(e) =>
setEditingUser({
...editingUser,
level: Math.max(1, parseInt(e.target.value) || 1),
})
}
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
/>
<button
onClick={() =>
setEditingUser({
...editingUser,
level: (editingUser.level || 1) + 1,
})
}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
>
+1
</button>
</div>
</div>
{/* Role Section */}
<div>
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
Rôle
</label>
<div className="flex gap-2">
<button
onClick={() =>
setEditingUser({
...editingUser,
role: "USER",
})
}
className={`flex-1 px-3 sm:px-4 py-2 border rounded text-[10px] sm:text-xs uppercase tracking-widest transition ${
editingUser.role === "USER"
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold"
: "border-gray-600/50 bg-gray-900/20 text-gray-400 hover:bg-gray-900/30"
}`}
>
USER
</button>
<button
onClick={() =>
setEditingUser({
...editingUser,
role: "ADMIN",
})
}
className={`flex-1 px-3 sm:px-4 py-2 border rounded text-[10px] sm:text-xs uppercase tracking-widest transition ${
editingUser.role === "ADMIN"
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold"
: "border-gray-600/50 bg-gray-900/20 text-gray-400 hover:bg-gray-900/30"
}`}
>
ADMIN
</button>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2">
<Button
onClick={handleSave}
variant="success"
size="md"
disabled={saving}
>
{saving ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button onClick={handleCancel} variant="secondary" size="md">
Annuler
</Button>
</div>
</div>
</div>
</Modal>
)}
</div>
);
}