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.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 33s
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 33s
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
265
app/feedback/[eventId]/FeedbackPageClient.tsx
Normal file
265
app/feedback/[eventId]/FeedbackPageClient.tsx
Normal file
@@ -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<Event | null>(null);
|
||||
const [existingFeedback, setExistingFeedback] = useState<Feedback | null>(
|
||||
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 (
|
||||
<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">
|
||||
<div className="text-white">Chargement...</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
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">
|
||||
<div className="text-red-400">Événement introuvable</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Background Image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url('${backgroundImage}')`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Form */}
|
||||
<div className="relative z-10 w-full max-w-2xl mx-auto px-8">
|
||||
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-8 backdrop-blur-sm">
|
||||
<h1 className="text-4xl font-gaming font-black mb-2 text-center">
|
||||
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
|
||||
FEEDBACK
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm text-center mb-2">
|
||||
{existingFeedback
|
||||
? "Modifier votre feedback pour"
|
||||
: "Donnez votre avis sur"}
|
||||
</p>
|
||||
<p className="text-pixel-gold text-lg font-semibold text-center mb-8">
|
||||
{event.name}
|
||||
</p>
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-900/50 border border-green-500/50 text-green-400 px-4 py-3 rounded text-sm mb-6">
|
||||
Feedback enregistré avec succès ! Redirection...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wider">
|
||||
Note
|
||||
</label>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
className={`text-4xl transition-transform hover:scale-110 ${
|
||||
star <= rating
|
||||
? "text-pixel-gold"
|
||||
: "text-gray-600 hover:text-gray-500"
|
||||
}`}
|
||||
aria-label={`Noter ${star} étoile${star > 1 ? "s" : ""}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs text-center mt-2">
|
||||
{rating > 0 && `${rating}/5`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="comment"
|
||||
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
|
||||
>
|
||||
Commentaire (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={6}
|
||||
maxLength={1000}
|
||||
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition resize-none"
|
||||
placeholder="Partagez votre expérience, vos suggestions..."
|
||||
/>
|
||||
<p className="text-gray-500 text-xs mt-1 text-right">
|
||||
{comment.length}/1000 caractères
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || rating === 0}
|
||||
className="w-full px-6 py-3 border border-pixel-gold/50 bg-black/60 text-white uppercase text-sm tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting
|
||||
? "Enregistrement..."
|
||||
: existingFeedback
|
||||
? "Modifier le feedback"
|
||||
: "Envoyer le feedback"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
"use client";
|
||||
import FeedbackPageClient from "./FeedbackPageClient";
|
||||
import { getBackgroundImage } from "@/lib/preferences";
|
||||
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Navigation from "@/components/Navigation";
|
||||
import { useBackgroundImage } from "@/hooks/usePreferences";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
description: string;
|
||||
interface FeedbackPageProps {
|
||||
params: {
|
||||
eventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
}
|
||||
export default async function FeedbackPage({ params }: FeedbackPageProps) {
|
||||
const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const { status } = useSession();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const eventId = params?.eventId as string;
|
||||
const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
|
||||
return <FeedbackPageClient backgroundImage={backgroundImage} />;
|
||||
}
|
||||
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
const [existingFeedback, setExistingFeedback] = useState<Feedback | null>(
|
||||
|
||||
@@ -2,6 +2,7 @@ import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
import EventsSection from "@/components/EventsSection";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBackgroundImage } from "@/lib/preferences";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,10 +20,13 @@ export default async function Home() {
|
||||
date: event.date.toISOString(),
|
||||
}));
|
||||
|
||||
// Récupérer l'image de fond côté serveur
|
||||
const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<NavigationWrapper />
|
||||
<HeroSection />
|
||||
<HeroSection backgroundImage={backgroundImage} />
|
||||
<EventsSection events={serializedEvents} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useRef, type ChangeEvent, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Navigation from "@/components/Navigation";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -321,23 +322,13 @@ export default function RegisterPage() {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Preview */}
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-pixel-gold/50 overflow-hidden bg-gray-900 flex items-center justify-center">
|
||||
{formData.avatar ? (
|
||||
<img
|
||||
src={formData.avatar}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : formData.username ? (
|
||||
<span className="text-pixel-gold text-3xl font-bold">
|
||||
{formData.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-pixel-gold text-3xl font-bold">
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Avatar
|
||||
src={formData.avatar}
|
||||
username={formData.username || "User"}
|
||||
size="xl"
|
||||
borderClassName="border-4 border-pixel-gold/50"
|
||||
fallbackText={formData.username ? undefined : "?"}
|
||||
/>
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
|
||||
<div className="text-pixel-gold text-xs">
|
||||
|
||||
Reference in New Issue
Block a user