Enhance Admin and Profile UI: Update admin page to display user preferences with improved layout and visuals. Add password change functionality to profile page, including form handling and validation. Refactor ImageSelector for better image preview and upload experience.

This commit is contained in:
Julien Froidefond
2025-12-09 14:02:27 +01:00
parent 4e38bd1e8e
commit b1f36f6210
5 changed files with 620 additions and 68 deletions

View File

@@ -230,16 +230,72 @@ export default function AdminPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-2 text-sm text-gray-400"> <div className="space-y-4">
<div> <div className="flex items-center gap-4">
Home: {preferences?.homeBackground || "Par défaut"} <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>
<div> <div className="flex items-center gap-4">
Events: {preferences?.eventsBackground || "Par défaut"} <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>
<div> <div className="flex items-center gap-4">
Leaderboard:{" "} <span className="text-pixel-gold font-bold min-w-[120px]">
{preferences?.leaderboardBackground || "Par défaut"} 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>
)} )}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function PUT(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const body = await request.json();
const { currentPassword, newPassword, confirmPassword } = body;
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
if (newPassword.length < 6) {
return NextResponse.json(
{ error: "Le nouveau mot de passe doit contenir au moins 6 caractères" },
{ status: 400 }
);
}
if (newPassword !== confirmPassword) {
return NextResponse.json(
{ error: "Les mots de passe ne correspondent pas" },
{ status: 400 }
);
}
// Récupérer l'utilisateur avec le mot de passe
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { password: true },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Vérifier l'ancien mot de passe
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: "Mot de passe actuel incorrect" },
{ status: 400 }
);
}
// Hasher le nouveau mot de passe
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Mettre à jour le mot de passe
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
});
return NextResponse.json({ message: "Mot de passe modifié avec succès" });
} catch (error) {
console.error("Error updating password:", error);
return NextResponse.json(
{ error: "Erreur lors de la modification du mot de passe" },
{ status: 500 }
);
}
}

View File

@@ -38,6 +38,13 @@ export default function ProfilePage() {
const [avatar, setAvatar] = useState<string | null>(null); const [avatar, setAvatar] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false); 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(() => { useEffect(() => {
if (status === "unauthenticated") { if (status === "unauthenticated") {
@@ -140,6 +147,44 @@ export default function ProfilePage() {
} }
}; };
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") { if (loading || status === "loading") {
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
@@ -356,6 +401,95 @@ export default function ProfilePage() {
</button> </button>
</div> </div>
</form> </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>
</div> </div>
</section> </section>

View File

@@ -2,9 +2,94 @@
import { useBackgroundImage } from "@/hooks/usePreferences"; import { useBackgroundImage } from "@/hooks/usePreferences";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react";
interface Particle {
width: number;
height: number;
left: number;
top: number;
duration: number;
delay: number;
shadow: number;
fadeIn: number;
fadeOut: number;
visibleDuration: number;
moveY1: number;
moveX1: number;
moveY2: number;
moveX2: number;
moveY3: number;
moveX3: number;
moveY4: number;
moveX4: number;
moveY5: number;
moveX5: number;
moveY6: number;
moveX6: number;
}
interface Orb {
width: number;
height: number;
left: number;
top: number;
duration: number;
delay: number;
}
export default function HeroSection() { export default function HeroSection() {
const backgroundImage = useBackgroundImage("home", "/got-2.jpg"); const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
const [particles, setParticles] = useState<Particle[]>([]);
const [orbs, setOrbs] = useState<Orb[]>([]);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Generate particles - more visible and dynamic
setParticles(
Array.from({ length: 30 }, () => {
const fadeIn = Math.random() * 5 + 2; // 2-7% of animation - faster fade in
const visibleDuration = Math.random() * 30 + 20; // 20-50% of animation
const fadeOut = Math.random() * 5 + 2; // 2-7% of animation - faster fade out
return {
width: Math.random() * 6 + 3,
height: Math.random() * 6 + 3,
left: Math.random() * 100,
top: Math.random() * 100,
duration: 10 + Math.random() * 15,
delay: Math.random() * 8,
shadow: Math.random() * 15 + 8,
fadeIn: fadeIn,
fadeOut: fadeOut,
visibleDuration: visibleDuration,
moveY1: 20 + Math.random() * 20,
moveX1: Math.random() * 10 - 5,
moveY2: 40 + Math.random() * 20,
moveX2: Math.random() * 15 - 7,
moveY3: 60 + Math.random() * 20,
moveX3: Math.random() * 10 - 5,
moveY4: 80 + Math.random() * 20,
moveX4: Math.random() * 10 - 5,
moveY5: 100 + Math.random() * 20,
moveX5: Math.random() * 10 - 5,
moveY6: 120 + Math.random() * 20,
moveX6: Math.random() * 10 - 5,
};
})
);
// Generate orbs
setOrbs(
Array.from({ length: 4 }, () => ({
width: 100 + Math.random() * 200,
height: 100 + Math.random() * 200,
left: Math.random() * 80,
top: Math.random() * 80,
duration: 20 + Math.random() * 15,
delay: Math.random() * 10,
}))
);
}, []);
return ( return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24"> <section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24">
@@ -16,7 +101,89 @@ export default function HeroSection() {
}} }}
> >
{/* Dark overlay for readability */} {/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div> <div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80 z-[1]"></div>
{/* Animated particles */}
{mounted && (
<div className="absolute inset-0 overflow-hidden z-[2]">
{particles.map((particle, i) => (
<div
key={i}
className="absolute rounded-full"
style={{
width: `${particle.width}px`,
height: `${particle.height}px`,
left: `${particle.left}%`,
top: `${particle.top}%`,
background: `radial-gradient(circle, rgba(218, 165, 32, 0.9) 0%, rgba(218, 165, 32, 0.4) 50%, transparent 100%)`,
animation: `float-particle-${i} ${particle.duration}s infinite ease-in-out`,
animationDelay: `${particle.delay}s`,
boxShadow: `0 0 ${
particle.shadow
}px rgba(218, 165, 32, 0.8), 0 0 ${
particle.shadow * 2
}px rgba(218, 165, 32, 0.4)`,
filter: "blur(0.5px)",
}}
/>
))}
</div>
)}
{/* Animated light rays */}
<div className="absolute inset-0 overflow-hidden opacity-30">
{[...Array(3)].map((_, i) => {
const rotation = -15 + i * 15;
return (
<div
key={i}
className="absolute w-1 bg-gradient-to-b from-transparent via-pixel-gold/20 to-transparent"
style={{
height: "100%",
left: `${20 + i * 30}%`,
transform: `rotate(${rotation}deg)`,
animation: `light-ray-${i} ${
8 + i * 2
}s infinite ease-in-out`,
animationDelay: `${i * 2}s`,
transformOrigin: "top center",
}}
/>
);
})}
</div>
{/* Glowing orbs */}
{mounted && (
<div className="absolute inset-0 overflow-hidden">
{orbs.map((orb, i) => (
<div
key={i}
className="absolute rounded-full blur-xl"
style={{
width: `${orb.width}px`,
height: `${orb.height}px`,
left: `${orb.left}%`,
top: `${orb.top}%`,
background: `radial-gradient(circle, rgba(218, 165, 32, 0.2) 0%, transparent 70%)`,
animation: `orb-float ${orb.duration}s infinite ease-in-out`,
animationDelay: `${orb.delay}s`,
}}
/>
))}
</div>
)}
{/* Shimmer effect */}
<div className="absolute inset-0 overflow-hidden">
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-pixel-gold/10 to-transparent"
style={{
transform: "skewX(-20deg)",
animation: "shimmer 8s infinite",
}}
/>
</div>
</div> </div>
{/* Hero Content */} {/* Hero Content */}
@@ -77,6 +244,108 @@ export default function HeroSection() {
transform: translateY(-20px); transform: translateY(-20px);
} }
} }
${particles
.map((particle, i) => {
const fadeInPercent = particle.fadeIn;
const visibleStart = fadeInPercent;
const visibleEnd = visibleStart + particle.visibleDuration;
const fadeOutStart = visibleEnd;
const fadeOutEnd = Math.min(100, fadeOutStart + particle.fadeOut);
return `
@keyframes float-particle-${i} {
0% {
transform: translateY(0px) translateX(0px) scale(0.8);
opacity: 0;
}
${fadeInPercent}% {
transform: translateY(-${particle.moveY1}px) translateX(${particle.moveX1}px) scale(1);
opacity: 0.9;
}
${visibleStart}% {
transform: translateY(-${particle.moveY2}px) translateX(${particle.moveX2}px) scale(1.1);
opacity: 1;
}
${visibleEnd}% {
transform: translateY(-${particle.moveY3}px) translateX(${particle.moveX3}px) scale(1.05);
opacity: 1;
}
${fadeOutStart}% {
transform: translateY(-${particle.moveY4}px) translateX(${particle.moveX4}px) scale(0.9);
opacity: 0.7;
}
${fadeOutEnd}% {
transform: translateY(-${particle.moveY5}px) translateX(${particle.moveX5}px) scale(0.5);
opacity: 0;
}
100% {
transform: translateY(-${particle.moveY6}px) translateX(${particle.moveX6}px) scale(0.3);
opacity: 0;
}
}
`;
})
.join("")}
@keyframes light-ray-0 {
0%,
100% {
opacity: 0.2;
transform: rotate(-15deg) scaleY(0.8);
}
50% {
opacity: 0.5;
transform: rotate(-15deg) scaleY(1.2);
}
}
@keyframes light-ray-1 {
0%,
100% {
opacity: 0.2;
transform: rotate(0deg) scaleY(0.8);
}
50% {
opacity: 0.5;
transform: rotate(0deg) scaleY(1.2);
}
}
@keyframes light-ray-2 {
0%,
100% {
opacity: 0.2;
transform: rotate(15deg) scaleY(0.8);
}
50% {
opacity: 0.5;
transform: rotate(15deg) scaleY(1.2);
}
}
@keyframes orb-float {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.2;
}
33% {
transform: translate(30px, -30px) scale(1.1);
opacity: 0.3;
}
66% {
transform: translate(-20px, 20px) scale(0.9);
opacity: 0.25;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%) skewX(-20deg);
}
100% {
transform: translateX(200%) skewX(-20deg);
}
}
`}</style> `}</style>
</section> </section>
); );

View File

@@ -79,71 +79,84 @@ export default function ImageSelector({
<div className="space-y-3"> <div className="space-y-3">
<label className="block text-sm text-gray-300 mb-1">{label}</label> <label className="block text-sm text-gray-300 mb-1">{label}</label>
{/* Prévisualisation */} <div className="flex gap-4">
{value && ( {/* Colonne gauche - Image */}
<div className="mb-3"> <div className="flex-shrink-0">
<div className="relative w-full h-48 border border-pixel-gold/30 rounded overflow-hidden bg-black/60"> {value ? (
<img <div className="relative w-48 h-32 border border-pixel-gold/30 rounded overflow-hidden bg-black/60">
src={value} <img
alt="Preview" src={value}
className="w-full h-full object-cover" alt="Preview"
onError={(e) => { className="w-full h-full object-cover"
e.currentTarget.src = "/got-2.jpg"; // Image par défaut en cas d'erreur onError={(e) => {
}} e.currentTarget.src = "/got-2.jpg"; // Image par défaut en cas d'erreur
}}
/>
<button
onClick={() => onChange("")}
className="absolute top-2 right-2 px-2 py-1 bg-red-900/80 text-red-200 text-xs rounded hover:bg-red-900 transition"
>
</button>
</div>
) : (
<div className="w-48 h-32 border border-pixel-gold/30 rounded bg-black/60 flex items-center justify-center">
<span className="text-xs text-gray-500">Aucune</span>
</div>
)}
</div>
{/* Colonne droite - Contrôles */}
<div className="flex-1 space-y-3">
{/* Input URL */}
<div className="flex gap-2">
<input
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleUrlSubmit()}
placeholder="https://example.com/image.jpg ou /image.jpg"
className="flex-1 px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/> />
<button <button
onClick={() => onChange("")} onClick={handleUrlSubmit}
className="absolute top-2 right-2 px-2 py-1 bg-red-900/80 text-red-200 text-xs rounded hover:bg-red-900 transition" 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"
> >
URL
</button> </button>
</div> </div>
<p className="text-xs text-gray-400 mt-1 truncate">{value}</p>
{/* Upload depuis le disque */}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
id={`file-${label}`}
/>
<label
htmlFor={`file-${label}`}
className={`flex-1 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 text-center cursor-pointer ${
uploading ? "opacity-50 cursor-not-allowed" : ""
}`}
>
{uploading ? "Upload..." : "Upload depuis le disque"}
</label>
<button
onClick={() => setShowGallery(!showGallery)}
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"
>
{showGallery ? "Masquer" : "Galerie"}
</button>
</div>
{/* Chemin de l'image */}
{value && (
<p className="text-xs text-gray-400 truncate">{value}</p>
)}
</div> </div>
)}
{/* Input URL */}
<div className="flex gap-2">
<input
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleUrlSubmit()}
placeholder="https://example.com/image.jpg ou /image.jpg"
className="flex-1 px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
<button
onClick={handleUrlSubmit}
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"
>
URL
</button>
</div>
{/* Upload depuis le disque */}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
id={`file-${label}`}
/>
<label
htmlFor={`file-${label}`}
className={`flex-1 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 text-center cursor-pointer ${
uploading ? "opacity-50 cursor-not-allowed" : ""
}`}
>
{uploading ? "Upload..." : "Upload depuis le disque"}
</label>
<button
onClick={() => setShowGallery(!showGallery)}
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"
>
{showGallery ? "Masquer" : "Galerie"}
</button>
</div> </div>
{/* Galerie d'images */} {/* Galerie d'images */}