Refactor page components to use NavigationWrapper and integrate Prisma for data fetching. Update EventsSection and LeaderboardSection to accept props for events and leaderboard data, enhancing performance and user experience. Implement user authentication in ProfilePage and AdminPage, ensuring secure access to user data.

This commit is contained in:
Julien Froidefond
2025-12-09 14:11:47 +01:00
parent b1f36f6210
commit 67131f6470
14 changed files with 1041 additions and 944 deletions

View File

@@ -1,328 +1,42 @@
"use client"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Role } from "@/prisma/generated/prisma/client";
import NavigationWrapper from "@/components/NavigationWrapper";
import AdminPanel from "@/components/AdminPanel";
import { useEffect, useState } from "react"; export default async function AdminPage() {
import { useSession } from "next-auth/react"; const session = await auth();
import { useRouter } from "next/navigation";
import Navigation from "@/components/Navigation";
import ImageSelector from "@/components/ImageSelector";
import UserManagement from "@/components/UserManagement";
import EventManagement from "@/components/EventManagement";
interface SitePreferences { if (!session?.user) {
id: string; redirect("/login");
homeBackground: string | null; }
eventsBackground: string | null;
leaderboardBackground: string | null;
}
type AdminSection = "preferences" | "users" | "events"; if (session.user.role !== Role.ADMIN) {
redirect("/");
}
export default function AdminPage() { // Récupérer les préférences globales du site
const { data: session, status } = useSession(); let sitePreferences = await prisma.sitePreferences.findUnique({
const router = useRouter(); where: { id: "global" },
const [activeSection, setActiveSection] =
useState<AdminSection>("preferences");
const [preferences, setPreferences] = useState<SitePreferences | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
homeBackground: "",
eventsBackground: "",
leaderboardBackground: "",
}); });
useEffect(() => { // Si elles n'existent pas, créer une entrée par défaut
if (status === "unauthenticated") { if (!sitePreferences) {
router.push("/login"); sitePreferences = await prisma.sitePreferences.create({
return; data: {
} id: "global",
homeBackground: null,
if (status === "authenticated" && session?.user?.role !== "ADMIN") { eventsBackground: null,
router.push("/"); leaderboardBackground: null,
return;
}
if (status === "authenticated" && session?.user?.role === "ADMIN") {
fetchPreferences();
}
}, [status, session, router]);
const fetchPreferences = async () => {
try {
const response = await fetch("/api/admin/preferences");
if (response.ok) {
const data = await response.json();
setPreferences(data);
setFormData({
homeBackground: data.homeBackground || "",
eventsBackground: data.eventsBackground || "",
leaderboardBackground: data.leaderboardBackground || "",
});
}
} catch (error) {
console.error("Error fetching preferences:", error);
} finally {
setLoading(false);
}
};
const handleEdit = () => {
setIsEditing(true);
};
const handleSave = async () => {
try {
const response = await fetch("/api/admin/preferences", {
method: "PUT",
headers: {
"Content-Type": "application/json",
}, },
body: JSON.stringify(formData),
}); });
if (response.ok) {
await fetchPreferences();
setIsEditing(false);
}
} catch (error) {
console.error("Error updating preferences:", error);
}
};
const handleCancel = () => {
setIsEditing(false);
if (preferences) {
setFormData({
homeBackground: preferences.homeBackground || "",
eventsBackground: preferences.eventsBackground || "",
leaderboardBackground: preferences.leaderboardBackground || "",
});
}
};
if (status === "loading" || loading) {
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<div className="flex items-center justify-center min-h-screen text-pixel-gold">
Chargement...
</div>
</main>
);
} }
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<Navigation /> <NavigationWrapper />
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16"> <AdminPanel initialPreferences={sitePreferences} />
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
<h1 className="text-4xl font-gaming font-black mb-8 text-center">
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
ADMIN
</span>
</h1>
{/* Navigation Tabs */}
<div className="flex gap-4 mb-8 justify-center">
<button
onClick={() => setActiveSection("preferences")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "preferences"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Préférences UI
</button>
<button
onClick={() => setActiveSection("users")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "users"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Utilisateurs
</button>
<button
onClick={() => setActiveSection("events")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "events"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Événements
</button>
</div>
{activeSection === "preferences" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Préférences UI Globales
</h2>
<div className="space-y-4">
<div className="bg-black/60 border border-pixel-gold/20 rounded p-4">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-pixel-gold font-bold text-lg">
Images de fond du site
</h3>
<p className="text-gray-400 text-sm">
Ces préférences s'appliquent à tous les utilisateurs
</p>
</div>
{!isEditing && (
<button
onClick={handleEdit}
className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition"
>
Modifier
</button>
)}
</div>
{isEditing ? (
<div className="space-y-6">
<ImageSelector
value={formData.homeBackground}
onChange={(url) =>
setFormData({
...formData,
homeBackground: url,
})
}
label="Background Home"
/>
<ImageSelector
value={formData.eventsBackground}
onChange={(url) =>
setFormData({
...formData,
eventsBackground: url,
})
}
label="Background Events"
/>
<ImageSelector
value={formData.leaderboardBackground}
onChange={(url) =>
setFormData({
...formData,
leaderboardBackground: url,
})
}
label="Background Leaderboard"
/>
<div className="flex gap-2 pt-4">
<button
onClick={handleSave}
className="px-4 py-2 border border-green-500/50 bg-green-900/20 text-green-400 uppercase text-xs tracking-widest rounded hover:bg-green-900/30 transition"
>
Enregistrer
</button>
<button
onClick={handleCancel}
className="px-4 py-2 border border-gray-600/50 bg-gray-900/20 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/30 transition"
>
Annuler
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Home:
</span>
{preferences?.homeBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.homeBackground}
alt="Home background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.homeBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Events:
</span>
{preferences?.eventsBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.eventsBackground}
alt="Events background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.eventsBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Leaderboard:
</span>
{preferences?.leaderboardBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.leaderboardBackground}
alt="Leaderboard background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.leaderboardBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
{activeSection === "users" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Utilisateurs
</h2>
<UserManagement />
</div>
)}
{activeSection === "events" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Événements
</h2>
<EventManagement />
</div>
)}
</div>
</section>
</main> </main>
); );
} }

View File

@@ -1,12 +1,21 @@
import Navigation from "@/components/Navigation"; import NavigationWrapper from "@/components/NavigationWrapper";
import EventsPageSection from "@/components/EventsPageSection"; import EventsPageSection from "@/components/EventsPageSection";
import { prisma } from "@/lib/prisma";
import { getBackgroundImage } from "@/lib/preferences";
export default async function EventsPage() {
const events = await prisma.event.findMany({
orderBy: {
date: "asc",
},
});
const backgroundImage = await getBackgroundImage("events", "/got-2.jpg");
export default function EventsPage() {
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<Navigation /> <NavigationWrapper />
<EventsPageSection /> <EventsPageSection events={events} backgroundImage={backgroundImage} />
</main> </main>
); );
} }

View File

@@ -1,12 +1,51 @@
import Navigation from "@/components/Navigation"; import NavigationWrapper from "@/components/NavigationWrapper";
import LeaderboardSection from "@/components/LeaderboardSection"; import LeaderboardSection from "@/components/LeaderboardSection";
import { prisma } from "@/lib/prisma";
import { getBackgroundImage } from "@/lib/preferences";
interface LeaderboardEntry {
rank: number;
username: string;
score: number;
level: number;
avatar: string | null;
}
export default async function LeaderboardPage() {
const users = await prisma.user.findMany({
orderBy: {
score: "desc",
},
take: 10,
select: {
id: true,
username: true,
score: true,
level: true,
avatar: true,
},
});
const leaderboard: LeaderboardEntry[] = users.map((user, index) => ({
rank: index + 1,
username: user.username,
score: user.score,
level: user.level,
avatar: user.avatar,
}));
const backgroundImage = await getBackgroundImage(
"leaderboard",
"/leaderboard-bg.jpg"
);
export default function LeaderboardPage() {
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<Navigation /> <NavigationWrapper />
<LeaderboardSection /> <LeaderboardSection
leaderboard={leaderboard}
backgroundImage={backgroundImage}
/>
</main> </main>
); );
} }

View File

@@ -1,13 +1,21 @@
import Navigation from "@/components/Navigation"; import NavigationWrapper from "@/components/NavigationWrapper";
import HeroSection from "@/components/HeroSection"; import HeroSection from "@/components/HeroSection";
import EventsSection from "@/components/EventsSection"; import EventsSection from "@/components/EventsSection";
import { prisma } from "@/lib/prisma";
export default async function Home() {
const events = await prisma.event.findMany({
orderBy: {
date: "asc",
},
take: 3,
});
export default function Home() {
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<Navigation /> <NavigationWrapper />
<HeroSection /> <HeroSection />
<EventsSection /> <EventsSection events={events} />
</main> </main>
); );
} }

View File

@@ -1,498 +1,44 @@
"use client"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getBackgroundImage } from "@/lib/preferences";
import NavigationWrapper from "@/components/NavigationWrapper";
import ProfileForm from "@/components/ProfileForm";
import { useEffect, useState, useRef } from "react"; export default async function ProfilePage() {
import { useSession } from "next-auth/react"; const session = await auth();
import { useRouter } from "next/navigation";
import Navigation from "@/components/Navigation";
import { useBackgroundImage } from "@/hooks/usePreferences";
interface UserProfile { if (!session?.user) {
id: string; redirect("/login");
email: string;
username: string;
avatar: string | null;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
level: number;
score: number;
createdAt: string;
}
const formatNumber = (num: number): string => {
return num.toLocaleString("en-US");
};
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
const backgroundImage = useBackgroundImage("home", "/got-background.jpg");
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [avatar, setAvatar] = useState<string | null>(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 [changingPassword, setChangingPassword] = useState(false);
useEffect(() => {
if (status === "unauthenticated") {
router.push("/login");
return;
} }
if (status === "authenticated" && session?.user) { const user = await prisma.user.findUnique({
fetchProfile(); where: { id: session.user.id },
} select: {
}, [status, session, router]); id: true,
email: true,
const fetchProfile = async () => { username: true,
try { avatar: true,
const response = await fetch("/api/profile"); hp: true,
if (response.ok) { maxHp: true,
const data = await response.json(); xp: true,
setProfile(data); maxXp: true,
setUsername(data.username); level: true,
setAvatar(data.avatar); score: true,
} else { createdAt: true,
setError("Erreur lors du chargement du profil");
}
} catch (err) {
console.error("Error fetching profile:", err);
setError("Erreur lors du chargement du profil");
} finally {
setLoading(false);
}
};
const handleAvatarUpload = async (e: React.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();
setSaving(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
}, },
body: JSON.stringify({
username,
avatar,
}),
}); });
if (response.ok) { if (!user) {
const data = await response.json(); redirect("/login");
setProfile(data);
setSuccess("Profil mis à jour avec succès");
setTimeout(() => setSuccess(null), 3000);
} else {
const errorData = await response.json();
setError(errorData.error || "Erreur lors de la mise à jour");
}
} catch (err) {
console.error("Error updating profile:", err);
setError("Erreur lors de la mise à jour du profil");
} finally {
setSaving(false);
}
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setChangingPassword(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/profile/password", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
if (response.ok) {
setSuccess("Mot de passe modifié avec succès");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setShowPasswordForm(false);
setTimeout(() => setSuccess(null), 3000);
} else {
const errorData = await response.json();
setError(errorData.error || "Erreur lors de la modification du mot de passe");
}
} catch (err) {
console.error("Error changing password:", err);
setError("Erreur lors de la modification du mot de passe");
} finally {
setChangingPassword(false);
}
};
if (loading || status === "loading") {
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
<div className="text-pixel-gold text-xl">Chargement...</div>
</section>
</main>
);
} }
if (!profile) { const backgroundImage = await getBackgroundImage("home", "/got-background.jpg");
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
<div className="text-red-400 text-xl">Erreur lors du chargement du profil</div>
</section>
</main>
);
}
const hpPercentage = (profile.hp / profile.maxHp) * 100;
const xpPercentage = (profile.xp / profile.maxXp) * 100;
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 ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<Navigation /> <NavigationWrapper />
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16"> <ProfileForm initialProfile={user} backgroundImage={backgroundImage} />
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('${backgroundImage}')`,
}}
>
{/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Content */}
<div className="relative z-10 w-full max-w-4xl mx-auto px-8 py-16">
{/* Title Section */}
<div className="text-center mb-12">
<h1 className="text-5xl md:text-7xl font-gaming font-black mb-4 tracking-tight">
<span
className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent"
style={{
textShadow: "0 0 30px rgba(218, 165, 32, 0.5)",
}}
>
PROFIL
</span>
</h1>
<div className="text-pixel-gold text-lg md:text-xl font-gaming-subtitle font-semibold flex items-center justify-center gap-2 tracking-wide">
<span></span>
<span>Gérez votre profil</span>
<span></span>
</div>
</div>
{/* Profile Card */}
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg overflow-hidden backdrop-blur-sm">
<form onSubmit={handleSubmit} className="p-8 space-y-8">
{/* Messages */}
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
{success && (
<div className="bg-green-900/50 border border-green-500/50 text-green-400 px-4 py-3 rounded text-sm">
{success}
</div>
)}
{/* Avatar Section */}
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="w-32 h-32 rounded-full border-4 border-pixel-gold/50 overflow-hidden bg-gray-900 flex items-center justify-center">
{avatar ? (
<img
src={avatar}
alt={username}
className="w-full h-full object-cover"
/>
) : (
<span className="text-pixel-gold text-4xl font-bold">
{username.charAt(0).toUpperCase()}
</span>
)}
</div>
{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>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
id="avatar-upload"
/>
<label
htmlFor="avatar-upload"
className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition cursor-pointer inline-block"
>
{uploadingAvatar ? "Upload en cours..." : "Changer l'avatar"}
</label>
</div>
</div>
{/* Username Field */}
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Nom d'utilisateur
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
minLength={3}
maxLength={20}
/>
<p className="text-gray-500 text-xs mt-1">
3-20 caractères
</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 */}
<div className="mb-4">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>HP</span>
<span>{profile.hp} / {profile.maxHp}</span>
</div>
<div className="relative h-3 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>
</div>
{/* XP Bar */}
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>XP</span>
<span>{formatNumber(profile.xp)} / {formatNumber(profile.maxXp)}</span>
</div>
<div className="relative h-3 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>
</div>
</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"
disabled={saving}
className="px-6 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "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"
onClick={() => setShowPasswordForm(true)}
className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition"
>
Changer le mot de passe
</button>
)}
</div>
{showPasswordForm && (
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Mot de passe actuel
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
/>
</div>
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Nouveau mot de passe
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
minLength={6}
/>
<p className="text-gray-500 text-xs mt-1">
Minimum 6 caractères
</p>
</div>
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Confirmer le nouveau mot de passe
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
minLength={6}
/>
</div>
<div className="flex justify-end gap-4">
<button
type="button"
onClick={() => {
setShowPasswordForm(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setError(null);
}}
className="px-4 py-2 border border-gray-600/50 bg-black/40 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/40 hover:border-gray-500 transition"
>
Annuler
</button>
<button
type="submit"
disabled={changingPassword}
className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{changingPassword ? "Modification..." : "Modifier le mot de passe"}
</button>
</div>
</form>
)}
</div>
</div>
</div>
</section>
</main> </main>
); );
} }

281
components/AdminPanel.tsx Normal file
View File

@@ -0,0 +1,281 @@
"use client";
import { useState } from "react";
import ImageSelector from "@/components/ImageSelector";
import UserManagement from "@/components/UserManagement";
import EventManagement from "@/components/EventManagement";
interface SitePreferences {
id: string;
homeBackground: string | null;
eventsBackground: string | null;
leaderboardBackground: string | null;
}
interface AdminPanelProps {
initialPreferences: SitePreferences;
}
type AdminSection = "preferences" | "users" | "events";
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
const [activeSection, setActiveSection] =
useState<AdminSection>("preferences");
const [preferences, setPreferences] = useState<SitePreferences | null>(
initialPreferences
);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
homeBackground: initialPreferences.homeBackground || "",
eventsBackground: initialPreferences.eventsBackground || "",
leaderboardBackground: initialPreferences.leaderboardBackground || "",
});
const handleEdit = () => {
setIsEditing(true);
};
const handleSave = async () => {
try {
const response = await fetch("/api/admin/preferences", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (response.ok) {
const data = await response.json();
setPreferences(data);
setIsEditing(false);
}
} catch (error) {
console.error("Error updating preferences:", error);
}
};
const handleCancel = () => {
setIsEditing(false);
if (preferences) {
setFormData({
homeBackground: preferences.homeBackground || "",
eventsBackground: preferences.eventsBackground || "",
leaderboardBackground: preferences.leaderboardBackground || "",
});
}
};
return (
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
<h1 className="text-4xl font-gaming font-black mb-8 text-center">
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
ADMIN
</span>
</h1>
{/* Navigation Tabs */}
<div className="flex gap-4 mb-8 justify-center">
<button
onClick={() => setActiveSection("preferences")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "preferences"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Préférences UI
</button>
<button
onClick={() => setActiveSection("users")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "users"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Utilisateurs
</button>
<button
onClick={() => setActiveSection("events")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "events"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Événements
</button>
</div>
{activeSection === "preferences" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Préférences UI Globales
</h2>
<div className="space-y-4">
<div className="bg-black/60 border border-pixel-gold/20 rounded p-4">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-pixel-gold font-bold text-lg">
Images de fond du site
</h3>
<p className="text-gray-400 text-sm">
Ces préférences s'appliquent à tous les utilisateurs
</p>
</div>
{!isEditing && (
<button
onClick={handleEdit}
className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition"
>
Modifier
</button>
)}
</div>
{isEditing ? (
<div className="space-y-6">
<ImageSelector
value={formData.homeBackground}
onChange={(url) =>
setFormData({
...formData,
homeBackground: url,
})
}
label="Background Home"
/>
<ImageSelector
value={formData.eventsBackground}
onChange={(url) =>
setFormData({
...formData,
eventsBackground: url,
})
}
label="Background Events"
/>
<ImageSelector
value={formData.leaderboardBackground}
onChange={(url) =>
setFormData({
...formData,
leaderboardBackground: url,
})
}
label="Background Leaderboard"
/>
<div className="flex gap-2 pt-4">
<button
onClick={handleSave}
className="px-4 py-2 border border-green-500/50 bg-green-900/20 text-green-400 uppercase text-xs tracking-widest rounded hover:bg-green-900/30 transition"
>
Enregistrer
</button>
<button
onClick={handleCancel}
className="px-4 py-2 border border-gray-600/50 bg-gray-900/20 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/30 transition"
>
Annuler
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Home:
</span>
{preferences?.homeBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.homeBackground}
alt="Home background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.homeBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Events:
</span>
{preferences?.eventsBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.eventsBackground}
alt="Events background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.eventsBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Leaderboard:
</span>
{preferences?.leaderboardBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.leaderboardBackground}
alt="Leaderboard background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.leaderboardBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
{activeSection === "users" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Utilisateurs
</h2>
<UserManagement />
</div>
)}
{activeSection === "events" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Événements
</h2>
<EventManagement />
</div>
)}
</div>
</section>
);
}

View File

@@ -1,8 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useBackgroundImage } from "@/hooks/usePreferences";
interface Event { interface Event {
id: string; id: string;
date: string; date: string;
@@ -12,6 +9,11 @@ interface Event {
status: "UPCOMING" | "LIVE" | "PAST"; status: "UPCOMING" | "LIVE" | "PAST";
} }
interface EventsPageSectionProps {
events: Event[];
backgroundImage: string;
}
const getEventTypeColor = (type: Event["type"]) => { const getEventTypeColor = (type: Event["type"]) => {
switch (type) { switch (type) {
case "SUMMIT": case "SUMMIT":
@@ -65,31 +67,10 @@ const getStatusBadge = (status: Event["status"]) => {
} }
}; };
export default function EventsPageSection() { export default function EventsPageSection({
const [events, setEvents] = useState<Event[]>([]); events,
const [loading, setLoading] = useState(true); backgroundImage,
const backgroundImage = useBackgroundImage("events", "/got-2.jpg"); }: EventsPageSectionProps) {
useEffect(() => {
fetch("/api/events")
.then((res) => res.json())
.then((data) => {
setEvents(data);
setLoading(false);
})
.catch((err) => {
console.error("Error fetching events:", err);
setLoading(false);
});
}, []);
if (loading) {
return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
<div className="text-pixel-gold text-xl">Chargement...</div>
</section>
);
}
return ( return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16"> <section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
{/* Background Image */} {/* Background Image */}

View File

@@ -1,41 +1,14 @@
"use client";
import { useEffect, useState } from "react";
interface Event { interface Event {
id: string; id: string;
date: string; date: string;
name: string; name: string;
} }
export default function EventsSection() { interface EventsSectionProps {
const [events, setEvents] = useState<Event[]>([]); events: Event[];
const [loading, setLoading] = useState(true); }
useEffect(() => {
fetch("/api/events")
.then((res) => res.json())
.then((data) => {
// Prendre seulement les 3 premiers événements pour la section d'accueil
setEvents(data.slice(0, 3));
setLoading(false);
})
.catch((err) => {
console.error("Error fetching events:", err);
setLoading(false);
});
}, []);
if (loading) {
return (
<section className="w-full bg-gray-950 border-t border-pixel-gold/30 py-16">
<div className="max-w-7xl mx-auto px-8 text-center text-pixel-gold">
Chargement...
</div>
</section>
);
}
export default function EventsSection({ events }: EventsSectionProps) {
if (events.length === 0) { if (events.length === 0) {
return null; return null;
} }

View File

@@ -1,8 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useBackgroundImage } from "@/hooks/usePreferences";
interface LeaderboardEntry { interface LeaderboardEntry {
rank: number; rank: number;
username: string; username: string;
@@ -11,39 +8,20 @@ interface LeaderboardEntry {
avatar?: string | null; avatar?: string | null;
} }
interface LeaderboardSectionProps {
leaderboard: LeaderboardEntry[];
backgroundImage: string;
}
// Format number with consistent locale to avoid hydration mismatch // Format number with consistent locale to avoid hydration mismatch
const formatScore = (score: number): string => { const formatScore = (score: number): string => {
return score.toLocaleString("en-US"); return score.toLocaleString("en-US");
}; };
export default function LeaderboardSection() { export default function LeaderboardSection({
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]); leaderboard,
const [loading, setLoading] = useState(true); backgroundImage,
const backgroundImage = useBackgroundImage( }: LeaderboardSectionProps) {
"leaderboard",
"/leaderboard-bg.jpg"
);
useEffect(() => {
fetch("/api/leaderboard")
.then((res) => res.json())
.then((data) => {
setLeaderboard(data);
setLoading(false);
})
.catch((err) => {
console.error("Error fetching leaderboard:", err);
setLoading(false);
});
}, []);
if (loading) {
return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
<div className="text-pixel-gold text-xl">Chargement...</div>
</section>
);
}
return ( return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16"> <section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
{/* Background Image */} {/* Background Image */}

View File

@@ -4,9 +4,32 @@ import Link from "next/link";
import { useSession, signOut } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import PlayerStats from "./PlayerStats"; import PlayerStats from "./PlayerStats";
export default function Navigation() { interface UserData {
username: string;
avatar: string | null;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
level: number;
}
interface NavigationProps {
initialUserData?: UserData | null;
initialIsAdmin?: boolean;
}
export default function Navigation({
initialUserData,
initialIsAdmin,
}: NavigationProps) {
const { data: session } = useSession(); const { data: session } = useSession();
// Utiliser initialUserData pour déterminer l'état de connexion pendant l'hydratation
// Cela évite le clignottement au reload
const isAuthenticated = initialUserData !== null || session !== null;
const isAdmin = initialIsAdmin ?? session?.user?.role === "ADMIN";
return ( return (
<nav className="w-full fixed top-0 left-0 z-50 px-8 py-3 bg-black/80 backdrop-blur-sm border-b border-gray-800/30"> <nav className="w-full fixed top-0 left-0 z-50 px-8 py-3 bg-black/80 backdrop-blur-sm border-b border-gray-800/30">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="max-w-7xl mx-auto flex items-center justify-between">
@@ -42,7 +65,7 @@ export default function Navigation() {
> >
LEADERBOARD LEADERBOARD
</Link> </Link>
{session?.user?.role === "ADMIN" && ( {isAdmin && (
<Link <Link
href="/admin" href="/admin"
className="text-pixel-gold hover:text-orange-400 transition text-xs font-normal uppercase tracking-widest" className="text-pixel-gold hover:text-orange-400 transition text-xs font-normal uppercase tracking-widest"
@@ -54,9 +77,9 @@ export default function Navigation() {
{/* Right Side */} {/* Right Side */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{session ? ( {isAuthenticated ? (
<> <>
<PlayerStats /> <PlayerStats initialUserData={initialUserData} />
<Link <Link
href="/profile" href="/profile"
className="text-white hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest" className="text-white hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest"

View File

@@ -0,0 +1,42 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import Navigation from "./Navigation";
interface UserData {
username: string;
avatar: string | null;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
level: number;
}
export default async function NavigationWrapper() {
const session = await auth();
let userData: UserData | null = null;
const isAdmin = session?.user?.role === "ADMIN";
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
username: true,
avatar: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
},
});
if (user) {
userData = user;
}
}
return <Navigation initialUserData={userData} initialIsAdmin={isAdmin} />;
}

View File

@@ -3,24 +3,48 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
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 // Format number with consistent locale to avoid hydration mismatch
const formatNumber = (num: number): string => { const formatNumber = (num: number): string => {
return num.toLocaleString("en-US"); return num.toLocaleString("en-US");
}; };
export default function PlayerStats() { const defaultUserData: UserData = {
const { data: session } = useSession();
const [userData, setUserData] = useState({
username: "Guest", username: "Guest",
avatar: null as string | null, avatar: null,
hp: 1000, hp: 1000,
maxHp: 1000, maxHp: 1000,
xp: 0, xp: 0,
maxXp: 5000, maxXp: 5000,
level: 1, level: 1,
}); };
export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
const { data: session } = useSession();
const [userData, setUserData] = useState<UserData>(
initialUserData || defaultUserData
);
useEffect(() => { 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) { if (session?.user?.id) {
fetch(`/api/users/${session.user.id}`) fetch(`/api/users/${session.user.id}`)
.then((res) => res.json()) .then((res) => res.json())
@@ -50,38 +74,46 @@ export default function PlayerStats() {
}); });
}); });
} else { } else {
setUserData({ setUserData(defaultUserData);
username: "Guest",
avatar: null,
hp: 1000,
maxHp: 1000,
xp: 0,
maxXp: 5000,
level: 1,
});
} }
}, [session]); }, [session, initialUserData]);
const { username, avatar, hp, maxHp, xp, maxXp, level } = userData; const { username, avatar, hp, maxHp, xp, maxXp, level } = userData;
const [hpPercentage, setHpPercentage] = useState(0);
const [xpPercentage, setXpPercentage] = useState(0); // 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(() => { useEffect(() => {
// Animate HP bar // Si on a des données initiales, animer depuis 0 vers la valeur cible
if (initialUserData) {
const hpTimer = setTimeout(() => { const hpTimer = setTimeout(() => {
setHpPercentage((hp / maxHp) * 100); setHpPercentage(targetHpPercentage);
}, 100); }, 100);
// Animate XP bar
const xpTimer = setTimeout(() => { const xpTimer = setTimeout(() => {
setXpPercentage((xp / maxXp) * 100); setXpPercentage(targetXpPercentage);
}, 200); }, 200);
return () => { return () => {
clearTimeout(hpTimer); clearTimeout(hpTimer);
clearTimeout(xpTimer); clearTimeout(xpTimer);
}; };
}, [hp, maxHp, xp, maxXp]); } else {
// Sinon, mettre à jour directement (pour les pages Client Components)
setHpPercentage(targetHpPercentage);
setXpPercentage(targetXpPercentage);
}
}, [targetHpPercentage, targetXpPercentage, initialUserData]);
const hpColor = const hpColor =
hpPercentage > 60 hpPercentage > 60

446
components/ProfileForm.tsx Normal file
View File

@@ -0,0 +1,446 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
interface UserProfile {
id: string;
email: string;
username: string;
avatar: string | null;
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 router = useRouter();
const [profile, setProfile] = useState<UserProfile>(initialProfile);
const [saving, setSaving] = useState(false);
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 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 [changingPassword, setChangingPassword] = useState(false);
const handleAvatarUpload = async (e: React.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();
setSaving(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
avatar,
}),
});
if (response.ok) {
const data = await response.json();
setProfile(data);
setSuccess("Profil mis à jour avec succès");
setTimeout(() => setSuccess(null), 3000);
} else {
const errorData = await response.json();
setError(errorData.error || "Erreur lors de la mise à jour");
}
} catch (err) {
console.error("Error updating profile:", err);
setError("Erreur lors de la mise à jour du profil");
} finally {
setSaving(false);
}
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setChangingPassword(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/profile/password", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
if (response.ok) {
setSuccess("Mot de passe modifié avec succès");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setShowPasswordForm(false);
setTimeout(() => setSuccess(null), 3000);
} else {
const errorData = await response.json();
setError(errorData.error || "Erreur lors de la modification du mot de passe");
}
} catch (err) {
console.error("Error changing password:", err);
setError("Erreur lors de la modification du mot de passe");
} finally {
setChangingPassword(false);
}
};
const hpPercentage = (profile.hp / profile.maxHp) * 100;
const xpPercentage = (profile.xp / profile.maxXp) * 100;
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 (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('${backgroundImage}')`,
}}
>
{/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Content */}
<div className="relative z-10 w-full max-w-4xl mx-auto px-8 py-16">
{/* Title Section */}
<div className="text-center mb-12">
<h1 className="text-5xl md:text-7xl font-gaming font-black mb-4 tracking-tight">
<span
className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent"
style={{
textShadow: "0 0 30px rgba(218, 165, 32, 0.5)",
}}
>
PROFIL
</span>
</h1>
<div className="text-pixel-gold text-lg md:text-xl font-gaming-subtitle font-semibold flex items-center justify-center gap-2 tracking-wide">
<span></span>
<span>Gérez votre profil</span>
<span></span>
</div>
</div>
{/* Profile Card */}
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg overflow-hidden backdrop-blur-sm">
<form onSubmit={handleSubmit} className="p-8 space-y-8">
{/* Messages */}
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
{success && (
<div className="bg-green-900/50 border border-green-500/50 text-green-400 px-4 py-3 rounded text-sm">
{success}
</div>
)}
{/* Avatar Section */}
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="w-32 h-32 rounded-full border-4 border-pixel-gold/50 overflow-hidden bg-gray-900 flex items-center justify-center">
{avatar ? (
<img
src={avatar}
alt={username}
className="w-full h-full object-cover"
/>
) : (
<span className="text-pixel-gold text-4xl font-bold">
{username.charAt(0).toUpperCase()}
</span>
)}
</div>
{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>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
id="avatar-upload"
/>
<label
htmlFor="avatar-upload"
className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition cursor-pointer inline-block"
>
{uploadingAvatar ? "Upload en cours..." : "Changer l'avatar"}
</label>
</div>
</div>
{/* Username Field */}
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Nom d'utilisateur
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
minLength={3}
maxLength={20}
/>
<p className="text-gray-500 text-xs mt-1">
3-20 caractères
</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 */}
<div className="mb-4">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>HP</span>
<span>{profile.hp} / {profile.maxHp}</span>
</div>
<div className="relative h-3 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>
</div>
{/* XP Bar */}
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>XP</span>
<span>{formatNumber(profile.xp)} / {formatNumber(profile.maxXp)}</span>
</div>
<div className="relative h-3 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>
</div>
</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"
disabled={saving}
className="px-6 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "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"
onClick={() => setShowPasswordForm(true)}
className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition"
>
Changer le mot de passe
</button>
)}
</div>
{showPasswordForm && (
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Mot de passe actuel
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
/>
</div>
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Nouveau mot de passe
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
minLength={6}
/>
<p className="text-gray-500 text-xs mt-1">
Minimum 6 caractères
</p>
</div>
<div>
<label className="block text-pixel-gold text-sm uppercase tracking-widest mb-2">
Confirmer le nouveau mot de passe
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-black/40 border border-pixel-gold/30 rounded text-white focus:outline-none focus:border-pixel-gold transition"
required
minLength={6}
/>
</div>
<div className="flex justify-end gap-4">
<button
type="button"
onClick={() => {
setShowPasswordForm(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setError(null);
}}
className="px-4 py-2 border border-gray-600/50 bg-black/40 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/40 hover:border-gray-500 transition"
>
Annuler
</button>
<button
type="submit"
disabled={changingPassword}
className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{changingPassword ? "Modification..." : "Modifier le mot de passe"}
</button>
</div>
</form>
)}
</div>
</div>
</div>
</section>
);
}

25
lib/preferences.ts Normal file
View File

@@ -0,0 +1,25 @@
import { prisma } from "@/lib/prisma";
export async function getBackgroundImage(
page: "home" | "events" | "leaderboard",
defaultImage: string
): Promise<string> {
try {
const sitePreferences = await prisma.sitePreferences.findUnique({
where: { id: "global" },
});
if (!sitePreferences) {
return defaultImage;
}
const imageKey = `${page}Background` as keyof typeof sitePreferences;
const customImage = sitePreferences[imageKey];
return customImage || defaultImage;
} catch (error) {
console.error("Error fetching background image:", error);
return defaultImage;
}
}