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 */} +
+ +