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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m36s
This commit is contained in:
529
components/profile/ProfileForm.tsx
Normal file
529
components/profile/ProfileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user