From 447ef9d0767475c89f1ceca4e08eee662ff57437 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 9 Dec 2025 12:46:29 +0100 Subject: [PATCH] Add profile link to Navigation component for user access, enhancing navigation options. --- app/api/profile/avatar/route.ts | 66 ++++ app/api/profile/route.ts | 122 ++++++ app/profile/page.tsx | 365 ++++++++++++++++++ components/Navigation.tsx | 6 + ...5-8438093A-06C3-4A67-A256-80FE1B68E2BB.png | Bin 0 -> 937457 bytes ...0ytpoo2iznrpn-1765280614235-Sans titre.png | Bin 0 -> 2975942 bytes ...6-0F7F8906-0C08-4387-AF8D-EE5F015ABE89.png | Bin 0 -> 1086972 bytes 7 files changed, 559 insertions(+) create mode 100644 app/api/profile/avatar/route.ts create mode 100644 app/api/profile/route.ts create mode 100644 app/profile/page.tsx create mode 100644 public/uploads/avatar-cmiy95pzd0000ytpoo2iznrpn-1765280603375-8438093A-06C3-4A67-A256-80FE1B68E2BB.png create mode 100644 public/uploads/avatar-cmiy95pzd0000ytpoo2iznrpn-1765280614235-Sans titre.png create mode 100644 public/uploads/avatar-cmiy95pzd0000ytpoo2iznrpn-1765280626956-0F7F8906-0C08-4387-AF8D-EE5F015ABE89.png diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..3f3df3b --- /dev/null +++ b/app/api/profile/avatar/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; + +export async function POST(request: Request) { + try { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + return NextResponse.json({ error: "Aucun fichier fourni" }, { status: 400 }); + } + + // Vérifier le type de fichier + if (!file.type.startsWith("image/")) { + return NextResponse.json( + { error: "Le fichier doit être une image" }, + { status: 400 } + ); + } + + // Limiter la taille (par exemple 5MB) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + return NextResponse.json( + { error: "L'image est trop grande (max 5MB)" }, + { status: 400 } + ); + } + + // Créer le dossier uploads s'il n'existe pas + const uploadsDir = join(process.cwd(), "public", "uploads"); + if (!existsSync(uploadsDir)) { + await mkdir(uploadsDir, { recursive: true }); + } + + // Générer un nom de fichier unique avec l'ID utilisateur + const timestamp = Date.now(); + const filename = `avatar-${session.user.id}-${timestamp}-${file.name}`; + const filepath = join(uploadsDir, filename); + + // Convertir le fichier en buffer et l'écrire + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + await writeFile(filepath, buffer); + + // Retourner l'URL de l'image + const imageUrl = `/uploads/${filename}`; + return NextResponse.json({ url: imageUrl }); + } catch (error) { + console.error("Error uploading avatar:", error); + return NextResponse.json( + { error: "Erreur lors de l'upload de l'avatar" }, + { status: 500 } + ); + } +} + diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts new file mode 100644 index 0000000..630c141 --- /dev/null +++ b/app/api/profile/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + 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) { + return NextResponse.json({ error: "Utilisateur non trouvé" }, { status: 404 }); + } + + return NextResponse.json(user); + } catch (error) { + console.error("Error fetching profile:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération du profil" }, + { status: 500 } + ); + } +} + +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 { username, avatar } = body; + + // Validation + if (username !== undefined) { + if (typeof username !== "string" || username.trim().length === 0) { + return NextResponse.json( + { error: "Le nom d'utilisateur ne peut pas être vide" }, + { status: 400 } + ); + } + + if (username.length < 3 || username.length > 20) { + return NextResponse.json( + { error: "Le nom d'utilisateur doit contenir entre 3 et 20 caractères" }, + { status: 400 } + ); + } + + // Vérifier si le username est déjà pris par un autre utilisateur + const existingUser = await prisma.user.findFirst({ + where: { + username: username.trim(), + NOT: { id: session.user.id }, + }, + }); + + if (existingUser) { + return NextResponse.json( + { error: "Ce nom d'utilisateur est déjà pris" }, + { status: 400 } + ); + } + } + + // Mettre à jour l'utilisateur + const updateData: { username?: string; avatar?: string | null } = {}; + if (username !== undefined) { + updateData.username = username.trim(); + } + if (avatar !== undefined) { + updateData.avatar = avatar || null; + } + + const updatedUser = await prisma.user.update({ + where: { id: session.user.id }, + data: updateData, + select: { + id: true, + email: true, + username: true, + avatar: true, + hp: true, + maxHp: true, + xp: true, + maxXp: true, + level: true, + score: true, + }, + }); + + return NextResponse.json(updatedUser); + } catch (error) { + console.error("Error updating profile:", error); + return NextResponse.json( + { error: "Erreur lors de la mise à jour du profil" }, + { status: 500 } + ); + } +} + diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..7a12a54 --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,365 @@ +"use client"; + +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"; + +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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [username, setUsername] = useState(""); + const [avatar, setAvatar] = useState(null); + const fileInputRef = useRef(null); + const [uploadingAvatar, setUploadingAvatar] = 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) => { + 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); + } + }; + + if (loading || status === "loading") { + return ( +
+ +
+
Chargement...
+
+
+ ); + } + + if (!profile) { + return ( +
+ +
+
Erreur lors du chargement du profil
+
+
+ ); + } + + 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 ( +
+ +
+ {/* Background Image */} +
+ {/* Dark overlay for readability */} +
+
+ + {/* Content */} +
+ {/* Title Section */} +
+

+ + PROFIL + +

+
+ + Gérez votre profil + +
+
+ + {/* Profile Card */} +
+
+ {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Avatar Section */} +
+
+
+ {avatar ? ( + {username} + ) : ( + + {username.charAt(0).toUpperCase()} + + )} +
+ {uploadingAvatar && ( +
+
Upload...
+
+ )} +
+
+ + +
+
+ + {/* Username Field */} +
+ + 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} + /> +

+ 3-20 caractères +

+
+ + {/* Stats Display */} +
+

+ Statistiques +

+ +
+
+
Score
+
+ {formatNumber(profile.score)} +
+
+
+
Niveau
+
+ Lv.{profile.level} +
+
+
+ + {/* HP Bar */} +
+
+ HP + {profile.hp} / {profile.maxHp} +
+
+
+
+
+ + {/* XP Bar */} +
+
+ XP + {formatNumber(profile.xp)} / {formatNumber(profile.maxXp)} +
+
+
+
+
+
+ + {/* Email (read-only) */} +
+ + +
+ + {/* Submit Button */} +
+ +
+ +
+
+
+
+ ); +} + diff --git a/components/Navigation.tsx b/components/Navigation.tsx index b5a193c..95cc029 100644 --- a/components/Navigation.tsx +++ b/components/Navigation.tsx @@ -57,6 +57,12 @@ export default function Navigation() { {session ? ( <> + + PROFIL +