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:
@@ -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";
|
||||
import { useSession } from "next-auth/react";
|
||||
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";
|
||||
export default async function AdminPage() {
|
||||
const session = await auth();
|
||||
|
||||
interface SitePreferences {
|
||||
id: string;
|
||||
homeBackground: string | null;
|
||||
eventsBackground: string | null;
|
||||
leaderboardBackground: string | null;
|
||||
}
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
type AdminSection = "preferences" | "users" | "events";
|
||||
if (session.user.role !== Role.ADMIN) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
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: "",
|
||||
// Récupérer les préférences globales du site
|
||||
let sitePreferences = await prisma.sitePreferences.findUnique({
|
||||
where: { id: "global" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "authenticated" && session?.user?.role !== "ADMIN") {
|
||||
router.push("/");
|
||||
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>
|
||||
);
|
||||
// Si elles n'existent pas, créer une entrée par défaut
|
||||
if (!sitePreferences) {
|
||||
sitePreferences = await prisma.sitePreferences.create({
|
||||
data: {
|
||||
id: "global",
|
||||
homeBackground: null,
|
||||
eventsBackground: null,
|
||||
leaderboardBackground: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<Navigation />
|
||||
<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>
|
||||
<NavigationWrapper />
|
||||
<AdminPanel initialPreferences={sitePreferences} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import Navigation from "@/components/Navigation";
|
||||
import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
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 (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<Navigation />
|
||||
<EventsPageSection />
|
||||
<NavigationWrapper />
|
||||
<EventsPageSection events={events} backgroundImage={backgroundImage} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
import Navigation from "@/components/Navigation";
|
||||
import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
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 (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<Navigation />
|
||||
<LeaderboardSection />
|
||||
<NavigationWrapper />
|
||||
<LeaderboardSection
|
||||
leaderboard={leaderboard}
|
||||
backgroundImage={backgroundImage}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
16
app/page.tsx
16
app/page.tsx
@@ -1,13 +1,21 @@
|
||||
import Navigation from "@/components/Navigation";
|
||||
import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
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 (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<Navigation />
|
||||
<NavigationWrapper />
|
||||
<HeroSection />
|
||||
<EventsSection />
|
||||
<EventsSection events={events} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Navigation from "@/components/Navigation";
|
||||
import { useBackgroundImage } from "@/hooks/usePreferences";
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
fetchProfile();
|
||||
}
|
||||
}, [status, session, router]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/profile");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
setUsername(data.username);
|
||||
setAvatar(data.avatar);
|
||||
} else {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
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 user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
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";
|
||||
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">
|
||||
{/* 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>
|
||||
<NavigationWrapper />
|
||||
<ProfileForm initialProfile={user} backgroundImage={backgroundImage} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
281
components/AdminPanel.tsx
Normal file
281
components/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useBackgroundImage } from "@/hooks/usePreferences";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
date: string;
|
||||
@@ -12,6 +9,11 @@ interface Event {
|
||||
status: "UPCOMING" | "LIVE" | "PAST";
|
||||
}
|
||||
|
||||
interface EventsPageSectionProps {
|
||||
events: Event[];
|
||||
backgroundImage: string;
|
||||
}
|
||||
|
||||
const getEventTypeColor = (type: Event["type"]) => {
|
||||
switch (type) {
|
||||
case "SUMMIT":
|
||||
@@ -65,31 +67,10 @@ const getStatusBadge = (status: Event["status"]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventsPageSection() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const backgroundImage = useBackgroundImage("events", "/got-2.jpg");
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default function EventsPageSection({
|
||||
events,
|
||||
backgroundImage,
|
||||
}: EventsPageSectionProps) {
|
||||
return (
|
||||
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
|
||||
{/* Background Image */}
|
||||
|
||||
@@ -1,41 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
date: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function EventsSection() {
|
||||
const [events, setEvents] = useState<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>
|
||||
);
|
||||
}
|
||||
interface EventsSectionProps {
|
||||
events: Event[];
|
||||
}
|
||||
|
||||
export default function EventsSection({ events }: EventsSectionProps) {
|
||||
if (events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useBackgroundImage } from "@/hooks/usePreferences";
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
@@ -11,39 +8,20 @@ interface LeaderboardEntry {
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
interface LeaderboardSectionProps {
|
||||
leaderboard: LeaderboardEntry[];
|
||||
backgroundImage: string;
|
||||
}
|
||||
|
||||
// Format number with consistent locale to avoid hydration mismatch
|
||||
const formatScore = (score: number): string => {
|
||||
return score.toLocaleString("en-US");
|
||||
};
|
||||
|
||||
export default function LeaderboardSection() {
|
||||
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const backgroundImage = useBackgroundImage(
|
||||
"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>
|
||||
);
|
||||
}
|
||||
export default function LeaderboardSection({
|
||||
leaderboard,
|
||||
backgroundImage,
|
||||
}: LeaderboardSectionProps) {
|
||||
return (
|
||||
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
|
||||
{/* Background Image */}
|
||||
|
||||
@@ -4,9 +4,32 @@ import Link from "next/link";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
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();
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
@@ -42,7 +65,7 @@ export default function Navigation() {
|
||||
>
|
||||
LEADERBOARD
|
||||
</Link>
|
||||
{session?.user?.role === "ADMIN" && (
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
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 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{session ? (
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<PlayerStats />
|
||||
<PlayerStats initialUserData={initialUserData} />
|
||||
<Link
|
||||
href="/profile"
|
||||
className="text-white hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest"
|
||||
|
||||
42
components/NavigationWrapper.tsx
Normal file
42
components/NavigationWrapper.tsx
Normal 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} />;
|
||||
}
|
||||
|
||||
@@ -3,24 +3,48 @@
|
||||
import { useEffect, useState } from "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
|
||||
const formatNumber = (num: number): string => {
|
||||
return num.toLocaleString("en-US");
|
||||
};
|
||||
|
||||
export default function PlayerStats() {
|
||||
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({
|
||||
username: "Guest",
|
||||
avatar: null as string | null,
|
||||
hp: 1000,
|
||||
maxHp: 1000,
|
||||
xp: 0,
|
||||
maxXp: 5000,
|
||||
level: 1,
|
||||
});
|
||||
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())
|
||||
@@ -50,38 +74,46 @@ export default function PlayerStats() {
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setUserData({
|
||||
username: "Guest",
|
||||
avatar: null,
|
||||
hp: 1000,
|
||||
maxHp: 1000,
|
||||
xp: 0,
|
||||
maxXp: 5000,
|
||||
level: 1,
|
||||
});
|
||||
setUserData(defaultUserData);
|
||||
}
|
||||
}, [session]);
|
||||
}, [session, initialUserData]);
|
||||
|
||||
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(() => {
|
||||
// Animate HP bar
|
||||
const hpTimer = setTimeout(() => {
|
||||
setHpPercentage((hp / maxHp) * 100);
|
||||
}, 100);
|
||||
// Si on a des données initiales, animer depuis 0 vers la valeur cible
|
||||
if (initialUserData) {
|
||||
const hpTimer = setTimeout(() => {
|
||||
setHpPercentage(targetHpPercentage);
|
||||
}, 100);
|
||||
|
||||
// Animate XP bar
|
||||
const xpTimer = setTimeout(() => {
|
||||
setXpPercentage((xp / maxXp) * 100);
|
||||
}, 200);
|
||||
const xpTimer = setTimeout(() => {
|
||||
setXpPercentage(targetXpPercentage);
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(hpTimer);
|
||||
clearTimeout(xpTimer);
|
||||
};
|
||||
}, [hp, maxHp, xp, maxXp]);
|
||||
return () => {
|
||||
clearTimeout(hpTimer);
|
||||
clearTimeout(xpTimer);
|
||||
};
|
||||
} else {
|
||||
// Sinon, mettre à jour directement (pour les pages Client Components)
|
||||
setHpPercentage(targetHpPercentage);
|
||||
setXpPercentage(targetXpPercentage);
|
||||
}
|
||||
}, [targetHpPercentage, targetXpPercentage, initialUserData]);
|
||||
|
||||
const hpColor =
|
||||
hpPercentage > 60
|
||||
|
||||
446
components/ProfileForm.tsx
Normal file
446
components/ProfileForm.tsx
Normal 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
25
lib/preferences.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user