From ae08ed7793777a194cea185bdaa46f9a6c07c2bb Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 12 Dec 2025 08:46:31 +0100 Subject: [PATCH] Enhance image upload and background management: Update Docker configuration to create a dedicated backgrounds directory for uploaded images, modify API routes to handle background images specifically, and improve README documentation to reflect these changes. Additionally, refactor components to utilize the new Avatar component for consistent avatar rendering across the application. --- Dockerfile | 5 +- README.docker.md | 5 +- app/api/admin/images/list/route.ts | 21 +- app/api/admin/images/upload/route.ts | 11 +- app/api/admin/users/[id]/route.ts | 48 ++- app/feedback/[eventId]/FeedbackPageClient.tsx | 265 ++++++++++++ app/feedback/[eventId]/page.tsx | 33 +- app/page.tsx | 6 +- app/register/page.tsx | 25 +- components/AdminPanel.tsx | 330 +++------------ components/Avatar.tsx | 66 +++ components/BackgroundPreferences.tsx | 399 ++++++++++++++++++ components/HeroSection.tsx | 8 +- components/ImageSelector.tsx | 10 +- components/Leaderboard.tsx | 22 +- components/LeaderboardSection.tsx | 59 +-- components/PlayerStats.tsx | 20 +- components/ProfileForm.tsx | 20 +- components/UserManagement.tsx | 185 +++++++- docker-compose.yml | 2 +- hooks/usePreferences.ts | 24 +- public/uploads/backgrounds/got-2.jpg | Bin 0 -> 104591 bytes public/uploads/backgrounds/got-background.jpg | Bin 0 -> 170722 bytes public/uploads/backgrounds/leaderboard-bg.jpg | Bin 0 -> 120086 bytes 24 files changed, 1100 insertions(+), 464 deletions(-) create mode 100644 app/feedback/[eventId]/FeedbackPageClient.tsx create mode 100644 components/Avatar.tsx create mode 100644 components/BackgroundPreferences.tsx create mode 100644 public/uploads/backgrounds/got-2.jpg create mode 100644 public/uploads/backgrounds/got-background.jpg create mode 100644 public/uploads/backgrounds/leaderboard-bg.jpg diff --git a/Dockerfile b/Dockerfile index 93e203e..1449b00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,13 +51,14 @@ ENV DATABASE_URL="file:/app/data/dev.db" RUN pnpm add prisma @prisma/client @prisma/adapter-better-sqlite3 better-sqlite3 --prod && \ pnpm dlx prisma generate -# Create data directory for SQLite database and uploads directory -RUN mkdir -p /app/data /app/public/uploads && chown -R nextjs:nodejs /app/data /app/public/uploads +# Create data directory for SQLite database and uploads directories +RUN mkdir -p /app/data /app/public/uploads /app/public/uploads/backgrounds && chown -R nextjs:nodejs /app/data /app/public/uploads RUN echo '#!/bin/sh' > /app/entrypoint.sh && \ echo 'set -e' >> /app/entrypoint.sh && \ echo 'mkdir -p /app/data' >> /app/entrypoint.sh && \ echo 'mkdir -p /app/public/uploads' >> /app/entrypoint.sh && \ + echo 'mkdir -p /app/public/uploads/backgrounds' >> /app/entrypoint.sh && \ echo 'pnpm dlx prisma migrate deploy || true' >> /app/entrypoint.sh && \ echo 'exec node server.js' >> /app/entrypoint.sh && \ chmod +x /app/entrypoint.sh && \ diff --git a/README.docker.md b/README.docker.md index 403259b..c2b14a8 100644 --- a/README.docker.md +++ b/README.docker.md @@ -48,7 +48,10 @@ docker-compose exec got-app node node_modules/.bin/prisma migrate deploy ### Images uploadées -Les images uploadées (avatars, images d'événements, etc.) sont persistées dans un volume Docker. Par défaut, elles sont stockées dans `./uploads` à la racine du projet, mais vous pouvez personnaliser le chemin avec la variable d'environnement `UPLOADS_PATH`. +Les images uploadées (avatars et backgrounds) sont persistées dans un volume Docker. Par défaut, elles sont stockées dans `./uploads` à la racine du projet, mais vous pouvez personnaliser le chemin avec la variable d'environnement `UPLOADS_PATH`. + +- Les avatars sont stockés dans `uploads/` +- Les backgrounds sont stockés dans `uploads/backgrounds/` Exemple pour utiliser un chemin personnalisé : diff --git a/app/api/admin/images/list/route.ts b/app/api/admin/images/list/route.ts index afb2ffb..a660ab7 100644 --- a/app/api/admin/images/list/route.ts +++ b/app/api/admin/images/list/route.ts @@ -15,25 +15,18 @@ export async function GET() { const images: string[] = []; - // Lister les images dans public/ + // Lister uniquement les images dans public/uploads/backgrounds/ const publicDir = join(process.cwd(), "public"); - if (existsSync(publicDir)) { - const files = await readdir(publicDir); + const uploadsDir = join(publicDir, "uploads"); + const backgroundsDir = join(uploadsDir, "backgrounds"); + + if (existsSync(backgroundsDir)) { + const files = await readdir(backgroundsDir); const imageFiles = files.filter( (file) => file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && !file.startsWith(".") ); - images.push(...imageFiles.map((file) => `/${file}`)); - } - - // Lister les images dans public/uploads/ - const uploadsDir = join(publicDir, "uploads"); - if (existsSync(uploadsDir)) { - const uploadFiles = await readdir(uploadsDir); - const imageFiles = uploadFiles.filter((file) => - file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) - ); - images.push(...imageFiles.map((file) => `/uploads/${file}`)); + images.push(...imageFiles.map((file) => `/uploads/backgrounds/${file}`)); } return NextResponse.json({ images }); diff --git a/app/api/admin/images/upload/route.ts b/app/api/admin/images/upload/route.ts index 27b9106..776d3a8 100644 --- a/app/api/admin/images/upload/route.ts +++ b/app/api/admin/images/upload/route.ts @@ -31,16 +31,17 @@ export async function POST(request: Request) { ); } - // Créer le dossier uploads s'il n'existe pas + // Créer le dossier uploads/backgrounds s'il n'existe pas const uploadsDir = join(process.cwd(), "public", "uploads"); - if (!existsSync(uploadsDir)) { - await mkdir(uploadsDir, { recursive: true }); + const backgroundsDir = join(uploadsDir, "backgrounds"); + if (!existsSync(backgroundsDir)) { + await mkdir(backgroundsDir, { recursive: true }); } // Générer un nom de fichier unique const timestamp = Date.now(); const filename = `${timestamp}-${file.name}`; - const filepath = join(uploadsDir, filename); + const filepath = join(backgroundsDir, filename); // Convertir le fichier en buffer et l'écrire const bytes = await file.arrayBuffer(); @@ -48,7 +49,7 @@ export async function POST(request: Request) { await writeFile(filepath, buffer); // Retourner l'URL de l'image - const imageUrl = `/uploads/${filename}`; + const imageUrl = `/uploads/backgrounds/${filename}`; return NextResponse.json({ url: imageUrl }); } catch (error) { console.error("Error uploading image:", error); diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts index f263dbf..48b7a93 100644 --- a/app/api/admin/users/[id]/route.ts +++ b/app/api/admin/users/[id]/route.ts @@ -16,7 +16,7 @@ export async function PUT( const { id } = await params; const body = await request.json(); - const { hpDelta, xpDelta, score, level, role } = body; + const { username, avatar, hpDelta, xpDelta, score, level, role } = body; // Récupérer l'utilisateur actuel const user = await prisma.user.findUnique({ @@ -76,12 +76,14 @@ export async function PUT( } } - // Appliquer les changements directs (score, level, role) + // Appliquer les changements directs (username, avatar, score, level, role) const updateData: { hp: number; xp: number; level: number; maxXp: number; + username?: string; + avatar?: string | null; score?: number; role?: Role; } = { @@ -91,6 +93,48 @@ export async function PUT( maxXp: newMaxXp, }; + // Validation et mise à jour du username + 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 }, + }, + }); + + if (existingUser) { + return NextResponse.json( + { error: "Ce nom d'utilisateur est déjà pris" }, + { status: 400 } + ); + } + + updateData.username = username.trim(); + } + + // Mise à jour de l'avatar + if (avatar !== undefined) { + updateData.avatar = avatar || null; + } + if (score !== undefined) { updateData.score = Math.max(0, score); } diff --git a/app/feedback/[eventId]/FeedbackPageClient.tsx b/app/feedback/[eventId]/FeedbackPageClient.tsx new file mode 100644 index 0000000..f3efd4f --- /dev/null +++ b/app/feedback/[eventId]/FeedbackPageClient.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState, useEffect, type FormEvent } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter, useParams } from "next/navigation"; +import Navigation from "@/components/Navigation"; + +interface Event { + id: string; + name: string; + date: string; + description: string; +} + +interface Feedback { + id: string; + rating: number; + comment: string | null; +} + +interface FeedbackPageClientProps { + backgroundImage: string; +} + +export default function FeedbackPageClient({ + backgroundImage, +}: FeedbackPageClientProps) { + const { status } = useSession(); + const router = useRouter(); + const params = useParams(); + const eventId = params?.eventId as string; + + const [event, setEvent] = useState(null); + const [existingFeedback, setExistingFeedback] = useState( + null + ); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + const [rating, setRating] = useState(0); + const [comment, setComment] = useState(""); + + const fetchEventAndFeedback = async () => { + try { + // Récupérer l'événement + const eventResponse = await fetch(`/api/events/${eventId}`); + if (!eventResponse.ok) { + setError("Événement introuvable"); + setLoading(false); + return; + } + const eventData = await eventResponse.json(); + setEvent(eventData); + + // Récupérer le feedback existant si disponible + const feedbackResponse = await fetch(`/api/feedback/${eventId}`); + if (feedbackResponse.ok) { + const feedbackData = await feedbackResponse.json(); + if (feedbackData.feedback) { + setExistingFeedback(feedbackData.feedback); + setRating(feedbackData.feedback.rating); + setComment(feedbackData.feedback.comment || ""); + } + } + } catch { + setError("Erreur lors du chargement des données"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (status === "unauthenticated") { + router.push(`/login?redirect=/feedback/${eventId}`); + return; + } + + if (status === "authenticated" && eventId) { + fetchEventAndFeedback(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status, eventId, router]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(false); + + if (rating === 0) { + setError("Veuillez sélectionner une note"); + return; + } + + setSubmitting(true); + + try { + const response = await fetch(`/api/feedback/${eventId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + rating, + comment: comment.trim() || null, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Erreur lors de l'enregistrement"); + return; + } + + setSuccess(true); + setExistingFeedback(data.feedback); + + // Rediriger après 2 secondes + setTimeout(() => { + router.push("/events"); + }, 2000); + } catch { + setError("Erreur lors de l'enregistrement"); + } finally { + setSubmitting(false); + } + }; + + if (status === "loading" || loading) { + return ( +
+ +
+
Chargement...
+
+
+ ); + } + + if (!event) { + return ( +
+ +
+
Événement introuvable
+
+
+ ); + } + + return ( +
+ +
+ {/* Background Image */} +
+
+
+ + {/* Feedback Form */} +
+
+

+ + FEEDBACK + +

+

+ {existingFeedback + ? "Modifier votre feedback pour" + : "Donnez votre avis sur"} +

+

+ {event.name} +

+ + {success && ( +
+ Feedback enregistré avec succès ! Redirection... +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ {/* Rating */} +
+ +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+

+ {rating > 0 && `${rating}/5`} +

+
+ + {/* Comment */} +
+ +