Refactor component imports and structure: Update import paths for various components to improve organization, moving them into appropriate subdirectories. Remove unused components related to user and event management, enhancing code clarity and maintainability across the application.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m36s

This commit is contained in:
Julien Froidefond
2025-12-12 16:48:41 +01:00
parent 880e96d6e4
commit 97db800c73
27 changed files with 23 additions and 23 deletions

View File

@@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { Avatar } from "@/components/ui";
interface UserData {
username: string;
avatar: string | null;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
level: number;
}
interface PlayerStatsProps {
initialUserData?: UserData | null;
}
// Format number with consistent locale to avoid hydration mismatch
const formatNumber = (num: number): string => {
return num.toLocaleString("en-US");
};
const defaultUserData: UserData = {
username: "Guest",
avatar: null,
hp: 1000,
maxHp: 1000,
xp: 0,
maxXp: 5000,
level: 1,
};
export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
const { data: session } = useSession();
const [userData, setUserData] = useState<UserData>(
initialUserData || defaultUserData
);
useEffect(() => {
// Si on a déjà des données initiales, ne rien faire (déjà initialisé dans useState)
if (initialUserData) {
return;
}
// Sinon, fallback sur le fetch côté client (pour les pages Client Components)
if (session?.user?.id) {
fetch(`/api/users/${session.user.id}`)
.then((res) => res.json())
.then((data) => {
if (data) {
// Utiliser requestAnimationFrame pour éviter les cascades de rendu
requestAnimationFrame(() => {
setUserData({
username: data.username || "Guest",
avatar: data.avatar,
hp: data.hp || 1000,
maxHp: data.maxHp || 1000,
xp: data.xp || 0,
maxXp: data.maxXp || 5000,
level: data.level || 1,
});
});
}
})
.catch(() => {
// Utiliser les données de session si l'API échoue
requestAnimationFrame(() => {
setUserData({
username: session.user.username || "Guest",
avatar: null,
hp: 1000,
maxHp: 1000,
xp: 0,
maxXp: 5000,
level: 1,
});
});
});
} else if (!initialUserData) {
// Utiliser requestAnimationFrame pour éviter les cascades de rendu
requestAnimationFrame(() => {
setUserData(defaultUserData);
});
}
}, [session, initialUserData]);
const { username, avatar, hp, maxHp, xp, maxXp, level } = userData;
// Calculer les pourcentages cibles
const targetHpPercentage = (hp / maxHp) * 100;
const targetXpPercentage = (xp / maxXp) * 100;
// Initialiser les pourcentages à 0 si on a des données initiales (pour l'animation)
// Sinon utiliser directement les valeurs calculées
const [hpPercentage, setHpPercentage] = useState(
initialUserData ? 0 : targetHpPercentage
);
const [xpPercentage, setXpPercentage] = useState(
initialUserData ? 0 : targetXpPercentage
);
useEffect(() => {
// Si on a des données initiales, animer depuis 0 vers la valeur cible
if (initialUserData) {
const hpTimer = setTimeout(() => {
setHpPercentage(targetHpPercentage);
}, 100);
const xpTimer = setTimeout(() => {
setXpPercentage(targetXpPercentage);
}, 200);
return () => {
clearTimeout(hpTimer);
clearTimeout(xpTimer);
};
}
// Sinon, mettre à jour directement (pour les pages Client Components)
// Utiliser requestAnimationFrame pour éviter les cascades de rendu
requestAnimationFrame(() => {
setHpPercentage(targetHpPercentage);
setXpPercentage(targetXpPercentage);
});
}, [targetHpPercentage, targetXpPercentage, initialUserData]);
const hpColor =
hpPercentage > 60
? "from-green-600 to-green-700"
: hpPercentage > 30
? "from-yellow-600 to-orange-700"
: "from-red-700 to-red-900";
return (
<div className="flex items-center gap-3">
{/* Avatar */}
<Link
href="/profile"
className="cursor-pointer hover:opacity-80 transition-opacity"
>
<Avatar
src={avatar}
username={username}
size="md"
borderClassName="border-pixel-gold/20"
/>
</Link>
{/* Stats */}
<div className="flex flex-col gap-1.5 min-w-[180px] sm:min-w-[200px]">
{/* Username & Level */}
<div className="flex items-center gap-2">
<Link
href="/profile"
className="cursor-pointer hover:text-pixel-gold/80 transition-colors"
>
<div className="text-pixel-gold font-gaming font-bold text-sm tracking-wider">
{username}
</div>
</Link>
<div className="text-gray-400 font-pixel text-xs uppercase border border-pixel-gold/30 px-1.5 py-0.5 bg-black/40">
Lv.{level}
</div>
</div>
{/* Bars side by side */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{/* HP Bar */}
<div className="relative h-2 flex-1 bg-gray-900 border border-gray-700 rounded overflow-hidden">
<div
className={`absolute inset-0 bg-gradient-to-r ${hpColor} transition-all duration-1000 ease-out`}
style={{ width: `${hpPercentage}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
</div>
{hpPercentage < 30 && (
<div className="absolute inset-0 border border-red-500 rounded animate-pulse"></div>
)}
</div>
{/* XP Bar */}
<div className="relative h-2 flex-1 bg-gray-900 border border-pixel-gold/30 rounded overflow-hidden">
<div
className="absolute inset-0 bg-gradient-to-r from-pixel-gold/80 via-pixel-gold/70 to-pixel-gold/80 transition-all duration-1000 ease-out"
style={{ width: `${xpPercentage}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
</div>
</div>
</div>
{/* Labels */}
<div className="flex items-center gap-2 text-[8px] font-pixel text-gray-400">
<div className="flex-1 text-left">
HP {hp} / {maxHp}
</div>
<div className="flex-1 text-right">
XP {formatNumber(xp)} / {formatNumber(maxXp)}
</div>
</div>
</div>
</div>
<style jsx>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,529 @@
"use client";
import { useState, useRef, useTransition, type ChangeEvent } from "react";
import { Avatar, Input, Textarea, Button, Alert, Card, BackgroundSection, SectionTitle, ProgressBar } from "@/components/ui";
import { updateProfile } from "@/actions/profile/update-profile";
import { updatePassword } from "@/actions/profile/update-password";
type CharacterClass =
| "WARRIOR"
| "MAGE"
| "ROGUE"
| "RANGER"
| "PALADIN"
| "ENGINEER"
| "MERCHANT"
| "SCHOLAR"
| "BERSERKER"
| "NECROMANCER"
| null;
interface UserProfile {
id: string;
email: string;
username: string;
avatar: string | null;
bio: string | null;
characterClass: CharacterClass;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
level: number;
score: number;
createdAt: string;
}
interface ProfileFormProps {
initialProfile: UserProfile;
backgroundImage: string;
}
const formatNumber = (num: number): string => {
return num.toLocaleString("en-US");
};
export default function ProfileForm({
initialProfile,
backgroundImage,
}: ProfileFormProps) {
const [profile, setProfile] = useState<UserProfile>(initialProfile);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [username, setUsername] = useState(initialProfile.username);
const [avatar, setAvatar] = useState<string | null>(initialProfile.avatar);
const [bio, setBio] = useState<string | null>(initialProfile.bio || null);
const [characterClass, setCharacterClass] = useState<CharacterClass>(
initialProfile.characterClass || null
);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
// Password change form state
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isChangingPassword, startPasswordTransition] = useTransition();
const handleAvatarUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/profile/avatar", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
setAvatar(data.url);
setSuccess("Avatar mis à jour avec succès");
setTimeout(() => setSuccess(null), 3000);
} else {
const errorData = await response.json();
setError(errorData.error || "Erreur lors de l'upload de l'avatar");
}
} catch (err) {
console.error("Error uploading avatar:", err);
setError("Erreur lors de l'upload de l'avatar");
} finally {
setUploadingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
startTransition(async () => {
const result = await updateProfile({
username,
avatar,
bio,
characterClass,
});
if (result.success && result.data) {
setProfile({
...result.data,
createdAt: result.data.createdAt instanceof Date
? result.data.createdAt.toISOString()
: result.data.createdAt,
} as UserProfile);
setBio(result.data.bio || null);
setCharacterClass(result.data.characterClass as CharacterClass || null);
setSuccess("Profil mis à jour avec succès");
setTimeout(() => setSuccess(null), 3000);
} else {
setError(result.error || "Erreur lors de la mise à jour");
}
});
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
startPasswordTransition(async () => {
const result = await updatePassword({
currentPassword,
newPassword,
confirmPassword,
});
if (result.success) {
setSuccess(result.message || "Mot de passe modifié avec succès");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setShowPasswordForm(false);
setTimeout(() => setSuccess(null), 3000);
} else {
setError(result.error || "Erreur lors de la modification du mot de passe");
}
});
};
return (
<BackgroundSection backgroundImage={backgroundImage}>
<div className="w-full max-w-4xl mx-auto px-8">
{/* Title Section */}
<SectionTitle variant="gradient" size="lg" subtitle="Gérez votre profil" className="mb-12">
PROFIL
</SectionTitle>
{/* Profile Card */}
<Card variant="default" className="overflow-hidden">
<form onSubmit={handleSubmit} className="p-8 space-y-8">
{/* Messages */}
{error && <Alert variant="error">{error}</Alert>}
{success && <Alert variant="success">{success}</Alert>}
{/* Avatar Section */}
<div className="flex flex-col items-center gap-4">
<div className="relative">
<Avatar
src={avatar}
username={username}
size="2xl"
borderClassName="border-4 border-pixel-gold/50"
/>
{uploadingAvatar && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
<div className="text-pixel-gold text-sm">Upload...</div>
</div>
)}
</div>
{/* Avatars par défaut */}
<div className="flex flex-col items-center gap-3">
<label className="block text-pixel-gold text-xs uppercase tracking-widest mb-2">
Avatars par défaut
</label>
<div className="flex gap-3">
{[
"/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={() => {
setAvatar(defaultAvatar);
setSuccess("Avatar sélectionné");
setTimeout(() => setSuccess(null), 3000);
}}
className={`w-16 h-16 rounded-full border-2 overflow-hidden transition ${
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>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
id="avatar-upload"
/>
<label htmlFor="avatar-upload">
<Button variant="primary" size="md" as="span" className="cursor-pointer">
{uploadingAvatar ? "Upload en cours..." : "Upload un avatar custom"}
</Button>
</label>
</div>
</div>
{/* Username Field */}
<Input
type="text"
label="Nom d'utilisateur"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={20}
className="bg-black/40"
/>
<p className="text-gray-500 text-xs mt-1">3-20 caractères</p>
{/* Bio Field */}
<Textarea
label="Bio"
value={bio || ""}
onChange={(e) => setBio(e.target.value)}
rows={4}
maxLength={500}
showCharCount
placeholder="Parlez-nous de vous..."
className="bg-black/40"
/>
{/* Character Class Selection */}
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-3">
Classe de Personnage
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{
value: "WARRIOR",
name: "Guerrier",
icon: "⚔️",
desc: "Maître du combat au corps à corps",
},
{
value: "MAGE",
name: "Mage",
icon: "🔮",
desc: "Manipulateur des arcanes",
},
{
value: "ROGUE",
name: "Voleur",
icon: "🗡️",
desc: "Furtif et mortel",
},
{
value: "RANGER",
name: "Rôdeur",
icon: "🏹",
desc: "Chasseur des terres sauvages",
},
{
value: "PALADIN",
name: "Paladin",
icon: "🛡️",
desc: "Protecteur sacré",
},
{
value: "ENGINEER",
name: "Ingénieur",
icon: "⚙️",
desc: "Créateur d'artefacts",
},
{
value: "MERCHANT",
name: "Marchand",
icon: "💰",
desc: "Maître du commerce",
},
{
value: "SCHOLAR",
name: "Érudit",
icon: "📚",
desc: "Gardien du savoir",
},
{
value: "BERSERKER",
name: "Berserker",
icon: "🔥",
desc: "Rage destructrice",
},
{
value: "NECROMANCER",
name: "Nécromancien",
icon: "💀",
desc: "Maître des morts",
},
].map((cls) => (
<button
key={cls.value}
type="button"
onClick={() =>
setCharacterClass(cls.value as CharacterClass)
}
className={`p-4 border-2 rounded-lg text-left transition-all ${
characterClass === cls.value
? "border-pixel-gold bg-pixel-gold/20 shadow-lg shadow-pixel-gold/30"
: "border-pixel-gold/30 bg-black/40 hover:border-pixel-gold/50 hover:bg-black/60"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{cls.icon}</span>
<span
className={`font-bold text-sm uppercase tracking-wider ${
characterClass === cls.value
? "text-pixel-gold"
: "text-white"
}`}
>
{cls.name}
</span>
</div>
<p className="text-xs text-gray-400 leading-tight">
{cls.desc}
</p>
</button>
))}
</div>
{characterClass && (
<p className="text-pixel-gold text-xs mt-2 uppercase tracking-widest">
Classe sélectionnée
</p>
)}
</div>
{/* Stats Display */}
<div className="border-t border-pixel-gold/20 pt-6">
<h3 className="text-pixel-gold text-sm uppercase tracking-widest mb-4">
Statistiques
</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-black/40 border border-pixel-gold/20 rounded p-4">
<div className="text-gray-400 text-xs uppercase mb-1">
Score
</div>
<div className="text-pixel-gold text-xl font-bold">
{formatNumber(profile.score)}
</div>
</div>
<div className="bg-black/40 border border-pixel-gold/20 rounded p-4">
<div className="text-gray-400 text-xs uppercase mb-1">
Niveau
</div>
<div className="text-pixel-gold text-xl font-bold">
Lv.{profile.level}
</div>
</div>
</div>
{/* HP Bar */}
<ProgressBar
value={profile.hp}
max={profile.maxHp}
variant="hp"
showLabel
label="HP"
className="mb-4"
/>
{/* XP Bar */}
<ProgressBar
value={profile.xp}
max={profile.maxXp}
variant="xp"
showLabel
label="XP"
/>
</div>
{/* Email (read-only) */}
<div>
<label className="block text-gray-500 text-sm uppercase tracking-widest mb-2">
Email
</label>
<input
type="email"
value={profile.email}
disabled
className="w-full px-4 py-3 bg-black/20 border border-gray-700/50 rounded text-gray-500 cursor-not-allowed"
/>
</div>
{/* Submit Button */}
<div className="flex justify-end gap-4 pt-4 border-t border-pixel-gold/20">
<Button type="submit" variant="primary" size="md" disabled={isPending}>
{isPending ? "Enregistrement..." : "Enregistrer les modifications"}
</Button>
</div>
</form>
{/* Password Change Section - Separate form */}
<div className="border-t border-pixel-gold/20 p-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-pixel-gold text-sm uppercase tracking-widest">
Mot de passe
</h3>
{!showPasswordForm && (
<Button
type="button"
variant="primary"
size="md"
onClick={() => setShowPasswordForm(true)}
>
Changer le mot de passe
</Button>
)}
</div>
{showPasswordForm && (
<form onSubmit={handlePasswordChange} className="space-y-4">
<Input
type="password"
label="Mot de passe actuel"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="bg-black/40"
/>
<Input
type="password"
label="Nouveau mot de passe"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
className="bg-black/40"
/>
<p className="text-gray-500 text-xs mt-1">
Minimum 6 caractères
</p>
<Input
type="password"
label="Confirmer le nouveau mot de passe"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
className="bg-black/40"
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="secondary"
size="md"
onClick={() => {
setShowPasswordForm(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setError(null);
}}
>
Annuler
</Button>
<Button
type="submit"
variant="primary"
size="md"
disabled={isChangingPassword}
>
{isChangingPassword
? "Modification..."
: "Modifier le mot de passe"}
</Button>
</div>
</form>
)}
</div>
</Card>
</div>
</BackgroundSection>
);
}