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:
@@ -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 && \
|
RUN pnpm add prisma @prisma/client @prisma/adapter-better-sqlite3 better-sqlite3 --prod && \
|
||||||
pnpm dlx prisma generate
|
pnpm dlx prisma generate
|
||||||
|
|
||||||
# Create data directory for SQLite database and uploads directory
|
# Create data directory for SQLite database and uploads directories
|
||||||
RUN mkdir -p /app/data /app/public/uploads && chown -R nextjs:nodejs /app/data /app/public/uploads
|
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 && \
|
RUN echo '#!/bin/sh' > /app/entrypoint.sh && \
|
||||||
echo 'set -e' >> /app/entrypoint.sh && \
|
echo 'set -e' >> /app/entrypoint.sh && \
|
||||||
echo 'mkdir -p /app/data' >> /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' >> /app/entrypoint.sh && \
|
||||||
|
echo 'mkdir -p /app/public/uploads/backgrounds' >> /app/entrypoint.sh && \
|
||||||
echo 'pnpm dlx prisma migrate deploy || true' >> /app/entrypoint.sh && \
|
echo 'pnpm dlx prisma migrate deploy || true' >> /app/entrypoint.sh && \
|
||||||
echo 'exec node server.js' >> /app/entrypoint.sh && \
|
echo 'exec node server.js' >> /app/entrypoint.sh && \
|
||||||
chmod +x /app/entrypoint.sh && \
|
chmod +x /app/entrypoint.sh && \
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ docker-compose exec got-app node node_modules/.bin/prisma migrate deploy
|
|||||||
|
|
||||||
### Images uploadées
|
### 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é :
|
Exemple pour utiliser un chemin personnalisé :
|
||||||
|
|
||||||
|
|||||||
@@ -15,25 +15,18 @@ export async function GET() {
|
|||||||
|
|
||||||
const images: string[] = [];
|
const images: string[] = [];
|
||||||
|
|
||||||
// Lister les images dans public/
|
// Lister uniquement les images dans public/uploads/backgrounds/
|
||||||
const publicDir = join(process.cwd(), "public");
|
const publicDir = join(process.cwd(), "public");
|
||||||
if (existsSync(publicDir)) {
|
const uploadsDir = join(publicDir, "uploads");
|
||||||
const files = await readdir(publicDir);
|
const backgroundsDir = join(uploadsDir, "backgrounds");
|
||||||
|
|
||||||
|
if (existsSync(backgroundsDir)) {
|
||||||
|
const files = await readdir(backgroundsDir);
|
||||||
const imageFiles = files.filter(
|
const imageFiles = files.filter(
|
||||||
(file) =>
|
(file) =>
|
||||||
file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && !file.startsWith(".")
|
file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && !file.startsWith(".")
|
||||||
);
|
);
|
||||||
images.push(...imageFiles.map((file) => `/${file}`));
|
images.push(...imageFiles.map((file) => `/uploads/backgrounds/${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}`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ images });
|
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");
|
const uploadsDir = join(process.cwd(), "public", "uploads");
|
||||||
if (!existsSync(uploadsDir)) {
|
const backgroundsDir = join(uploadsDir, "backgrounds");
|
||||||
await mkdir(uploadsDir, { recursive: true });
|
if (!existsSync(backgroundsDir)) {
|
||||||
|
await mkdir(backgroundsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Générer un nom de fichier unique
|
// Générer un nom de fichier unique
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const filename = `${timestamp}-${file.name}`;
|
const filename = `${timestamp}-${file.name}`;
|
||||||
const filepath = join(uploadsDir, filename);
|
const filepath = join(backgroundsDir, filename);
|
||||||
|
|
||||||
// Convertir le fichier en buffer et l'écrire
|
// Convertir le fichier en buffer et l'écrire
|
||||||
const bytes = await file.arrayBuffer();
|
const bytes = await file.arrayBuffer();
|
||||||
@@ -48,7 +49,7 @@ export async function POST(request: Request) {
|
|||||||
await writeFile(filepath, buffer);
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
// Retourner l'URL de l'image
|
// Retourner l'URL de l'image
|
||||||
const imageUrl = `/uploads/${filename}`;
|
const imageUrl = `/uploads/backgrounds/${filename}`;
|
||||||
return NextResponse.json({ url: imageUrl });
|
return NextResponse.json({ url: imageUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading image:", error);
|
console.error("Error uploading image:", error);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function PUT(
|
|||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
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
|
// Récupérer l'utilisateur actuel
|
||||||
const user = await prisma.user.findUnique({
|
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: {
|
const updateData: {
|
||||||
hp: number;
|
hp: number;
|
||||||
xp: number;
|
xp: number;
|
||||||
level: number;
|
level: number;
|
||||||
maxXp: number;
|
maxXp: number;
|
||||||
|
username?: string;
|
||||||
|
avatar?: string | null;
|
||||||
score?: number;
|
score?: number;
|
||||||
role?: Role;
|
role?: Role;
|
||||||
} = {
|
} = {
|
||||||
@@ -91,6 +93,48 @@ export async function PUT(
|
|||||||
maxXp: newMaxXp,
|
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) {
|
if (score !== undefined) {
|
||||||
updateData.score = Math.max(0, score);
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import Navigation from "@/components/Navigation";
|
|
||||||
import { useBackgroundImage } from "@/hooks/usePreferences";
|
|
||||||
|
|
||||||
interface Event {
|
interface FeedbackPageProps {
|
||||||
id: string;
|
params: {
|
||||||
name: string;
|
eventId: string;
|
||||||
date: string;
|
};
|
||||||
description: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Feedback {
|
export default async function FeedbackPage({ params }: FeedbackPageProps) {
|
||||||
id: string;
|
const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
|
||||||
rating: number;
|
|
||||||
comment: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FeedbackPage() {
|
return <FeedbackPageClient backgroundImage={backgroundImage} />;
|
||||||
const { status } = useSession();
|
}
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const eventId = params?.eventId as string;
|
|
||||||
const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
|
|
||||||
|
|
||||||
const [event, setEvent] = useState<Event | null>(null);
|
const [event, setEvent] = useState<Event | null>(null);
|
||||||
const [existingFeedback, setExistingFeedback] = useState<Feedback | null>(
|
const [existingFeedback, setExistingFeedback] = useState<Feedback | null>(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import NavigationWrapper from "@/components/NavigationWrapper";
|
|||||||
import HeroSection from "@/components/HeroSection";
|
import HeroSection from "@/components/HeroSection";
|
||||||
import EventsSection from "@/components/EventsSection";
|
import EventsSection from "@/components/EventsSection";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getBackgroundImage } from "@/lib/preferences";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -19,10 +20,13 @@ export default async function Home() {
|
|||||||
date: event.date.toISOString(),
|
date: event.date.toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Récupérer l'image de fond côté serveur
|
||||||
|
const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black relative">
|
<main className="min-h-screen bg-black relative">
|
||||||
<NavigationWrapper />
|
<NavigationWrapper />
|
||||||
<HeroSection />
|
<HeroSection backgroundImage={backgroundImage} />
|
||||||
<EventsSection events={serializedEvents} />
|
<EventsSection events={serializedEvents} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useRef, type ChangeEvent, type FormEvent } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Navigation from "@/components/Navigation";
|
import Navigation from "@/components/Navigation";
|
||||||
|
import Avatar from "@/components/Avatar";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -321,23 +322,13 @@ export default function RegisterPage() {
|
|||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
{/* Preview */}
|
{/* Preview */}
|
||||||
<div className="relative">
|
<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">
|
<Avatar
|
||||||
{formData.avatar ? (
|
|
||||||
<img
|
|
||||||
src={formData.avatar}
|
src={formData.avatar}
|
||||||
alt="Avatar"
|
username={formData.username || "User"}
|
||||||
className="w-full h-full object-cover"
|
size="xl"
|
||||||
|
borderClassName="border-4 border-pixel-gold/50"
|
||||||
|
fallbackText={formData.username ? undefined : "?"}
|
||||||
/>
|
/>
|
||||||
) : 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>
|
|
||||||
{uploadingAvatar && (
|
{uploadingAvatar && (
|
||||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
|
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
|
||||||
<div className="text-pixel-gold text-xs">
|
<div className="text-pixel-gold text-xs">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ImageSelector from "@/components/ImageSelector";
|
|
||||||
import UserManagement from "@/components/UserManagement";
|
import UserManagement from "@/components/UserManagement";
|
||||||
import EventManagement from "@/components/EventManagement";
|
import EventManagement from "@/components/EventManagement";
|
||||||
import FeedbackManagement from "@/components/FeedbackManagement";
|
import FeedbackManagement from "@/components/FeedbackManagement";
|
||||||
|
import BackgroundPreferences from "@/components/BackgroundPreferences";
|
||||||
|
|
||||||
interface SitePreferences {
|
interface SitePreferences {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,111 +22,21 @@ type AdminSection = "preferences" | "users" | "events" | "feedbacks";
|
|||||||
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<AdminSection>("preferences");
|
useState<AdminSection>("preferences");
|
||||||
const [preferences, setPreferences] = useState<SitePreferences | null>(
|
|
||||||
initialPreferences
|
|
||||||
);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
homeBackground: initialPreferences.homeBackground || "",
|
|
||||||
eventsBackground: initialPreferences.eventsBackground || "",
|
|
||||||
leaderboardBackground: initialPreferences.leaderboardBackground || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/preferences", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setPreferences(data);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating preferences:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
if (preferences) {
|
|
||||||
setFormData({
|
|
||||||
homeBackground: preferences.homeBackground || "",
|
|
||||||
eventsBackground: preferences.eventsBackground || "",
|
|
||||||
leaderboardBackground: preferences.leaderboardBackground || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
|
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
|
||||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-4 sm:px-8 py-16">
|
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
|
||||||
<h1 className="text-2xl sm:text-4xl font-gaming font-black mb-8 text-center break-words">
|
<h1 className="text-4xl font-gaming font-black mb-8 text-center">
|
||||||
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
|
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
|
||||||
ADMIN
|
ADMIN
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className="mb-8">
|
<div className="flex gap-4 mb-8 justify-center">
|
||||||
{/* Mobile: Grid layout */}
|
|
||||||
<div className="grid grid-cols-2 sm:hidden gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection("preferences")}
|
onClick={() => setActiveSection("preferences")}
|
||||||
className={`px-3 py-2.5 border uppercase text-xs tracking-wider rounded transition ${
|
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
|
||||||
activeSection === "preferences"
|
|
||||||
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold font-semibold"
|
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Préférences
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSection("users")}
|
|
||||||
className={`px-3 py-2.5 border uppercase text-xs tracking-wider rounded transition ${
|
|
||||||
activeSection === "users"
|
|
||||||
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold font-semibold"
|
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Utilisateurs
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSection("events")}
|
|
||||||
className={`px-3 py-2.5 border uppercase text-xs tracking-wider rounded transition ${
|
|
||||||
activeSection === "events"
|
|
||||||
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold font-semibold"
|
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Événements
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSection("feedbacks")}
|
|
||||||
className={`px-3 py-2.5 border uppercase text-xs tracking-wider rounded transition ${
|
|
||||||
activeSection === "feedbacks"
|
|
||||||
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold font-semibold"
|
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Feedbacks
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: Horizontal tabs */}
|
|
||||||
<div className="hidden sm:flex gap-4 justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSection("preferences")}
|
|
||||||
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition whitespace-nowrap ${
|
|
||||||
activeSection === "preferences"
|
activeSection === "preferences"
|
||||||
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
||||||
@@ -136,7 +46,7 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection("users")}
|
onClick={() => setActiveSection("users")}
|
||||||
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition whitespace-nowrap ${
|
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
|
||||||
activeSection === "users"
|
activeSection === "users"
|
||||||
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
||||||
@@ -146,7 +56,7 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection("events")}
|
onClick={() => setActiveSection("events")}
|
||||||
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition whitespace-nowrap ${
|
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
|
||||||
activeSection === "events"
|
activeSection === "events"
|
||||||
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
||||||
@@ -156,7 +66,7 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection("feedbacks")}
|
onClick={() => setActiveSection("feedbacks")}
|
||||||
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition whitespace-nowrap ${
|
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
|
||||||
activeSection === "feedbacks"
|
activeSection === "feedbacks"
|
||||||
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
|
||||||
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
|
||||||
@@ -165,7 +75,6 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
|||||||
Feedbacks
|
Feedbacks
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeSection === "preferences" && (
|
{activeSection === "preferences" && (
|
||||||
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-4 sm:p-6 backdrop-blur-sm">
|
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-4 sm:p-6 backdrop-blur-sm">
|
||||||
@@ -173,156 +82,13 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
|||||||
Préférences UI Globales
|
Préférences UI Globales
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-black/60 border border-pixel-gold/20 rounded p-3 sm:p-4">
|
<BackgroundPreferences initialPreferences={initialPreferences} />
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
|
||||||
Images de fond du site
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-400 text-xs sm:text-sm">
|
|
||||||
Ces préférences s'appliquent à tous les utilisateurs
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!isEditing && (
|
|
||||||
<button
|
|
||||||
onClick={handleEdit}
|
|
||||||
className="px-3 sm:px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-[10px] sm:text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition whitespace-nowrap flex-shrink-0"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ImageSelector
|
|
||||||
value={formData.homeBackground}
|
|
||||||
onChange={(url) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
homeBackground: url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
label="Background Home"
|
|
||||||
/>
|
|
||||||
<ImageSelector
|
|
||||||
value={formData.eventsBackground}
|
|
||||||
onChange={(url) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
eventsBackground: url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
label="Background Events"
|
|
||||||
/>
|
|
||||||
<ImageSelector
|
|
||||||
value={formData.leaderboardBackground}
|
|
||||||
onChange={(url) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
leaderboardBackground: url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
label="Background Leaderboard"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="px-4 py-2 border border-green-500/50 bg-green-900/20 text-green-400 uppercase text-xs tracking-widest rounded hover:bg-green-900/30 transition"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="px-4 py-2 border border-gray-600/50 bg-gray-900/20 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/30 transition"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
|
||||||
Home:
|
|
||||||
</span>
|
|
||||||
{preferences?.homeBackground ? (
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
|
||||||
<img
|
|
||||||
src={preferences.homeBackground}
|
|
||||||
alt="Home background"
|
|
||||||
className="w-16 h-10 sm:w-20 sm:h-12 object-cover rounded border border-pixel-gold/30 flex-shrink-0"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src = "/got-2.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-400 truncate min-w-0">
|
|
||||||
{preferences.homeBackground}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400 text-sm sm:text-base">
|
|
||||||
Par défaut
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
|
||||||
Events:
|
|
||||||
</span>
|
|
||||||
{preferences?.eventsBackground ? (
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
|
||||||
<img
|
|
||||||
src={preferences.eventsBackground}
|
|
||||||
alt="Events background"
|
|
||||||
className="w-16 h-10 sm:w-20 sm:h-12 object-cover rounded border border-pixel-gold/30 flex-shrink-0"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src = "/got-2.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-400 truncate min-w-0">
|
|
||||||
{preferences.eventsBackground}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400 text-sm sm:text-base">
|
|
||||||
Par défaut
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
|
||||||
Leaderboard:
|
|
||||||
</span>
|
|
||||||
{preferences?.leaderboardBackground ? (
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
|
||||||
<img
|
|
||||||
src={preferences.leaderboardBackground}
|
|
||||||
alt="Leaderboard background"
|
|
||||||
className="w-16 h-10 sm:w-20 sm:h-12 object-cover rounded border border-pixel-gold/30 flex-shrink-0"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src = "/got-2.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-400 truncate min-w-0">
|
|
||||||
{preferences.leaderboardBackground}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400 text-sm sm:text-base">
|
|
||||||
Par défaut
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSection === "users" && (
|
{activeSection === "users" && (
|
||||||
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-4 sm:p-6 backdrop-blur-sm">
|
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
|
||||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||||
Gestion des Utilisateurs
|
Gestion des Utilisateurs
|
||||||
</h2>
|
</h2>
|
||||||
@@ -331,7 +97,7 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSection === "events" && (
|
{activeSection === "events" && (
|
||||||
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-4 sm:p-6 backdrop-blur-sm">
|
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
|
||||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||||
Gestion des Événements
|
Gestion des Événements
|
||||||
</h2>
|
</h2>
|
||||||
@@ -340,7 +106,7 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSection === "feedbacks" && (
|
{activeSection === "feedbacks" && (
|
||||||
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-4 sm:p-6 backdrop-blur-sm">
|
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
|
||||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||||
Gestion des Feedbacks
|
Gestion des Feedbacks
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
66
components/Avatar.tsx
Normal file
66
components/Avatar.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
src: string | null | undefined;
|
||||||
|
username: string;
|
||||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||||
|
className?: string;
|
||||||
|
borderClassName?: string;
|
||||||
|
fallbackText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: "w-6 h-6 text-[8px]",
|
||||||
|
sm: "w-8 h-8 text-[10px]",
|
||||||
|
md: "w-10 h-10 text-xs",
|
||||||
|
lg: "w-16 h-16 sm:w-20 sm:h-20 text-xl sm:text-2xl",
|
||||||
|
xl: "w-24 h-24 text-4xl",
|
||||||
|
"2xl": "w-32 h-32 text-4xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Avatar({
|
||||||
|
src,
|
||||||
|
username,
|
||||||
|
size = "md",
|
||||||
|
className = "",
|
||||||
|
borderClassName = "",
|
||||||
|
fallbackText,
|
||||||
|
}: AvatarProps) {
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
const prevSrcRef = useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
|
// Reset error state when src changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (src !== prevSrcRef.current) {
|
||||||
|
prevSrcRef.current = src;
|
||||||
|
setAvatarError(false);
|
||||||
|
}
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const sizeClass = sizeClasses[size];
|
||||||
|
const displaySrc = src && !avatarError ? src : null;
|
||||||
|
const initial = fallbackText || username.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${sizeClass} rounded-full border overflow-hidden bg-black/60 flex items-center justify-center relative ${className} ${borderClassName}`}
|
||||||
|
>
|
||||||
|
{displaySrc ? (
|
||||||
|
<img
|
||||||
|
key={displaySrc}
|
||||||
|
src={displaySrc}
|
||||||
|
alt={username}
|
||||||
|
className="w-full h-full object-cover absolute inset-0"
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={`text-pixel-gold font-bold ${displaySrc ? "hidden" : ""}`}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
399
components/BackgroundPreferences.tsx
Normal file
399
components/BackgroundPreferences.tsx
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import ImageSelector from "@/components/ImageSelector";
|
||||||
|
|
||||||
|
interface SitePreferences {
|
||||||
|
id: string;
|
||||||
|
homeBackground: string | null;
|
||||||
|
eventsBackground: string | null;
|
||||||
|
leaderboardBackground: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackgroundPreferencesProps {
|
||||||
|
initialPreferences: SitePreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_IMAGES = {
|
||||||
|
home: "/got-2.jpg",
|
||||||
|
events: "/got-2.jpg",
|
||||||
|
leaderboard: "/leaderboard-bg.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BackgroundPreferences({
|
||||||
|
initialPreferences,
|
||||||
|
}: BackgroundPreferencesProps) {
|
||||||
|
const [preferences, setPreferences] = useState<SitePreferences | null>(
|
||||||
|
initialPreferences
|
||||||
|
);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
// Helper pour obtenir la valeur à afficher dans le formulaire
|
||||||
|
const getFormValue = (
|
||||||
|
dbValue: string | null | undefined,
|
||||||
|
defaultImage: string
|
||||||
|
) => {
|
||||||
|
return dbValue && dbValue.trim() !== "" ? dbValue : defaultImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper pour obtenir la valeur à envoyer à l'API
|
||||||
|
const getApiValue = (formValue: string, defaultImage: string) => {
|
||||||
|
return formValue === defaultImage ? "" : formValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
homeBackground: getFormValue(
|
||||||
|
initialPreferences.homeBackground,
|
||||||
|
DEFAULT_IMAGES.home
|
||||||
|
),
|
||||||
|
eventsBackground: getFormValue(
|
||||||
|
initialPreferences.eventsBackground,
|
||||||
|
DEFAULT_IMAGES.events
|
||||||
|
),
|
||||||
|
leaderboardBackground: getFormValue(
|
||||||
|
initialPreferences.leaderboardBackground,
|
||||||
|
DEFAULT_IMAGES.leaderboard
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Synchroniser les préférences quand initialPreferences change
|
||||||
|
useEffect(() => {
|
||||||
|
setPreferences(initialPreferences);
|
||||||
|
setFormData({
|
||||||
|
homeBackground: getFormValue(
|
||||||
|
initialPreferences.homeBackground,
|
||||||
|
DEFAULT_IMAGES.home
|
||||||
|
),
|
||||||
|
eventsBackground: getFormValue(
|
||||||
|
initialPreferences.eventsBackground,
|
||||||
|
DEFAULT_IMAGES.events
|
||||||
|
),
|
||||||
|
leaderboardBackground: getFormValue(
|
||||||
|
initialPreferences.leaderboardBackground,
|
||||||
|
DEFAULT_IMAGES.leaderboard
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [initialPreferences]);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
// Convertir les valeurs du formulaire en valeurs API ("" si c'est l'image par défaut)
|
||||||
|
const apiData = {
|
||||||
|
homeBackground: getApiValue(
|
||||||
|
formData.homeBackground,
|
||||||
|
DEFAULT_IMAGES.home
|
||||||
|
),
|
||||||
|
eventsBackground: getApiValue(
|
||||||
|
formData.eventsBackground,
|
||||||
|
DEFAULT_IMAGES.events
|
||||||
|
),
|
||||||
|
leaderboardBackground: getApiValue(
|
||||||
|
formData.leaderboardBackground,
|
||||||
|
DEFAULT_IMAGES.leaderboard
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/preferences", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(apiData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPreferences(data);
|
||||||
|
// Réinitialiser formData avec les nouvelles valeurs (ou images par défaut)
|
||||||
|
setFormData({
|
||||||
|
homeBackground: getFormValue(
|
||||||
|
data.homeBackground,
|
||||||
|
DEFAULT_IMAGES.home
|
||||||
|
),
|
||||||
|
eventsBackground: getFormValue(
|
||||||
|
data.eventsBackground,
|
||||||
|
DEFAULT_IMAGES.events
|
||||||
|
),
|
||||||
|
leaderboardBackground: getFormValue(
|
||||||
|
data.leaderboardBackground,
|
||||||
|
DEFAULT_IMAGES.leaderboard
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error("Error updating preferences:", errorData);
|
||||||
|
alert(errorData.error || "Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating preferences:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (preferences) {
|
||||||
|
setFormData({
|
||||||
|
homeBackground: getFormValue(
|
||||||
|
preferences.homeBackground,
|
||||||
|
DEFAULT_IMAGES.home
|
||||||
|
),
|
||||||
|
eventsBackground: getFormValue(
|
||||||
|
preferences.eventsBackground,
|
||||||
|
DEFAULT_IMAGES.events
|
||||||
|
),
|
||||||
|
leaderboardBackground: getFormValue(
|
||||||
|
preferences.leaderboardBackground,
|
||||||
|
DEFAULT_IMAGES.leaderboard
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-black/60 border border-pixel-gold/20 rounded p-3 sm:p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||||
|
Images de fond du site
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-xs sm:text-sm">
|
||||||
|
Ces préférences s'appliquent à tous les utilisateurs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="px-3 sm:px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-[10px] sm:text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition whitespace-nowrap flex-shrink-0"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ImageSelector
|
||||||
|
value={formData.homeBackground}
|
||||||
|
onChange={(url) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
homeBackground: url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Background Home"
|
||||||
|
/>
|
||||||
|
<ImageSelector
|
||||||
|
value={formData.eventsBackground}
|
||||||
|
onChange={(url) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
eventsBackground: url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Background Events"
|
||||||
|
/>
|
||||||
|
<ImageSelector
|
||||||
|
value={formData.leaderboardBackground}
|
||||||
|
onChange={(url) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
leaderboardBackground: url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Background Leaderboard"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 border border-green-500/50 bg-green-900/20 text-green-400 uppercase text-xs tracking-widest rounded hover:bg-green-900/30 transition"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 border border-gray-600/50 bg-gray-900/20 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/30 transition"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||||
|
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
||||||
|
Home:
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const currentImage =
|
||||||
|
preferences?.homeBackground &&
|
||||||
|
preferences.homeBackground.trim() !== ""
|
||||||
|
? preferences.homeBackground
|
||||||
|
: DEFAULT_IMAGES.home;
|
||||||
|
const isDefault =
|
||||||
|
!preferences?.homeBackground ||
|
||||||
|
preferences.homeBackground.trim() === "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||||
|
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={currentImage}
|
||||||
|
alt="Home background"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const currentSrc = target.src;
|
||||||
|
const fallbackSrc = "/got-2.jpg";
|
||||||
|
if (!currentSrc.includes(fallbackSrc)) {
|
||||||
|
target.src = fallbackSrc;
|
||||||
|
} else {
|
||||||
|
target.style.display = "none";
|
||||||
|
const fallbackDiv =
|
||||||
|
target.nextElementSibling as HTMLElement;
|
||||||
|
if (fallbackDiv) {
|
||||||
|
fallbackDiv.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="text-xs text-gray-400 truncate min-w-0">
|
||||||
|
{isDefault ? "Par défaut: " : ""}
|
||||||
|
{currentImage}
|
||||||
|
</span>
|
||||||
|
{isDefault && (
|
||||||
|
<span className="text-[10px] text-gray-500 italic">
|
||||||
|
(Image par défaut)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||||
|
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
||||||
|
Events:
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const currentImage =
|
||||||
|
preferences?.eventsBackground &&
|
||||||
|
preferences.eventsBackground.trim() !== ""
|
||||||
|
? preferences.eventsBackground
|
||||||
|
: DEFAULT_IMAGES.events;
|
||||||
|
const isDefault =
|
||||||
|
!preferences?.eventsBackground ||
|
||||||
|
preferences.eventsBackground.trim() === "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||||
|
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={currentImage}
|
||||||
|
alt="Events background"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const currentSrc = target.src;
|
||||||
|
const fallbackSrc = "/got-2.jpg";
|
||||||
|
if (!currentSrc.includes(fallbackSrc)) {
|
||||||
|
target.src = fallbackSrc;
|
||||||
|
} else {
|
||||||
|
target.style.display = "none";
|
||||||
|
const fallbackDiv =
|
||||||
|
target.nextElementSibling as HTMLElement;
|
||||||
|
if (fallbackDiv) {
|
||||||
|
fallbackDiv.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="text-xs text-gray-400 truncate min-w-0">
|
||||||
|
{isDefault ? "Par défaut: " : ""}
|
||||||
|
{currentImage}
|
||||||
|
</span>
|
||||||
|
{isDefault && (
|
||||||
|
<span className="text-[10px] text-gray-500 italic">
|
||||||
|
(Image par défaut)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||||
|
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
||||||
|
Leaderboard:
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const currentImage =
|
||||||
|
preferences?.leaderboardBackground &&
|
||||||
|
preferences.leaderboardBackground.trim() !== ""
|
||||||
|
? preferences.leaderboardBackground
|
||||||
|
: DEFAULT_IMAGES.leaderboard;
|
||||||
|
const isDefault =
|
||||||
|
!preferences?.leaderboardBackground ||
|
||||||
|
preferences.leaderboardBackground.trim() === "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||||
|
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={currentImage}
|
||||||
|
alt="Leaderboard background"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const currentSrc = target.src;
|
||||||
|
const fallbackSrc = "/got-2.jpg";
|
||||||
|
if (!currentSrc.includes(fallbackSrc)) {
|
||||||
|
target.src = fallbackSrc;
|
||||||
|
} else {
|
||||||
|
target.style.display = "none";
|
||||||
|
const fallbackDiv =
|
||||||
|
target.nextElementSibling as HTMLElement;
|
||||||
|
if (fallbackDiv) {
|
||||||
|
fallbackDiv.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="text-xs text-gray-400 truncate min-w-0">
|
||||||
|
{isDefault ? "Par défaut: " : ""}
|
||||||
|
{currentImage}
|
||||||
|
</span>
|
||||||
|
{isDefault && (
|
||||||
|
<span className="text-[10px] text-gray-500 italic">
|
||||||
|
(Image par défaut)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useBackgroundImage } from "@/hooks/usePreferences";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function HeroSection() {
|
interface HeroSectionProps {
|
||||||
const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
|
backgroundImage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroSection({ backgroundImage }: HeroSectionProps) {
|
||||||
|
|
||||||
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">
|
||||||
|
|||||||
@@ -90,7 +90,15 @@ export default function ImageSelector({
|
|||||||
alt="Preview"
|
alt="Preview"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.src = "/got-2.jpg"; // Image par défaut en cas d'erreur
|
const target = e.currentTarget;
|
||||||
|
// Ne pas boucler si l'image de fallback échoue aussi
|
||||||
|
const currentSrc = target.src;
|
||||||
|
const fallbackSrc = "/got-2.jpg";
|
||||||
|
if (!currentSrc.includes(fallbackSrc)) {
|
||||||
|
target.src = fallbackSrc;
|
||||||
|
} else {
|
||||||
|
target.style.display = "none";
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface LeaderboardEntry {
|
interface LeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
@@ -160,21 +161,12 @@ export default function Leaderboard() {
|
|||||||
|
|
||||||
{/* Avatar and Class */}
|
{/* Avatar and Class */}
|
||||||
<div className="flex items-center gap-6 mb-6">
|
<div className="flex items-center gap-6 mb-6">
|
||||||
{selectedEntry.avatar ? (
|
<Avatar
|
||||||
<div className="w-24 h-24 rounded-full border-4 border-pixel-gold/50 overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={selectedEntry.avatar}
|
src={selectedEntry.avatar}
|
||||||
alt={selectedEntry.username}
|
username={selectedEntry.username}
|
||||||
className="w-full h-full object-cover"
|
size="xl"
|
||||||
|
borderClassName="border-4 border-pixel-gold/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-24 h-24 rounded-full border-4 border-pixel-gold/50 bg-gray-900 flex items-center justify-center">
|
|
||||||
<span className="text-pixel-gold text-4xl font-bold">
|
|
||||||
{selectedEntry.username.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
|
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
|
||||||
Rank #{selectedEntry.rank}
|
Rank #{selectedEntry.rank}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface LeaderboardEntry {
|
interface LeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
@@ -105,28 +106,13 @@ export default function LeaderboardSection({
|
|||||||
|
|
||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div className="col-span-5 sm:col-span-6 flex items-center gap-2 sm:gap-3 min-w-0">
|
<div className="col-span-5 sm:col-span-6 flex items-center gap-2 sm:gap-3 min-w-0">
|
||||||
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
|
<Avatar
|
||||||
{entry.avatar ? (
|
|
||||||
<img
|
|
||||||
src={entry.avatar}
|
src={entry.avatar}
|
||||||
alt={entry.username}
|
username={entry.username}
|
||||||
className="w-full h-full object-cover"
|
size="sm"
|
||||||
onError={(e) => {
|
className="flex-shrink-0"
|
||||||
e.currentTarget.style.display = "none";
|
borderClassName="border-pixel-gold/30"
|
||||||
e.currentTarget.nextElementSibling?.classList.remove(
|
|
||||||
"hidden"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={`w-full h-full flex items-center justify-center text-pixel-gold text-[10px] sm:text-xs font-bold ${
|
|
||||||
entry.avatar ? "hidden" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{entry.username.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 sm:gap-2 cursor-pointer hover:opacity-80 transition min-w-0"
|
className="flex items-center gap-1 sm:gap-2 cursor-pointer hover:opacity-80 transition min-w-0"
|
||||||
onClick={() => setSelectedEntry(entry)}
|
onClick={() => setSelectedEntry(entry)}
|
||||||
@@ -213,28 +199,13 @@ export default function LeaderboardSection({
|
|||||||
|
|
||||||
{/* Avatar and Class */}
|
{/* Avatar and Class */}
|
||||||
<div className="flex items-center gap-4 sm:gap-6 mb-6">
|
<div className="flex items-center gap-4 sm:gap-6 mb-6">
|
||||||
<div className="w-16 h-16 sm:w-24 sm:h-24 rounded-full border-2 sm:border-4 border-pixel-gold/50 overflow-hidden bg-black/60 flex-shrink-0">
|
<Avatar
|
||||||
{selectedEntry.avatar ? (
|
|
||||||
<img
|
|
||||||
src={selectedEntry.avatar}
|
src={selectedEntry.avatar}
|
||||||
alt={selectedEntry.username}
|
username={selectedEntry.username}
|
||||||
className="w-full h-full object-cover"
|
size="lg"
|
||||||
onError={(e) => {
|
className="flex-shrink-0"
|
||||||
e.currentTarget.style.display = "none";
|
borderClassName="border-2 sm:border-4 border-pixel-gold/50"
|
||||||
e.currentTarget.nextElementSibling?.classList.remove(
|
|
||||||
"hidden"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={`w-full h-full flex items-center justify-center text-pixel-gold text-2xl sm:text-4xl font-bold ${
|
|
||||||
selectedEntry.avatar ? "hidden" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selectedEntry.username.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
|
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
|
||||||
Rank #{selectedEntry.rank}
|
Rank #{selectedEntry.rank}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface UserData {
|
interface UserData {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -140,19 +141,12 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
|||||||
href="/profile"
|
href="/profile"
|
||||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-full border border-pixel-gold/20 overflow-hidden bg-gray-900 flex items-center justify-center">
|
<Avatar
|
||||||
{avatar ? (
|
|
||||||
<img
|
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={username}
|
username={username}
|
||||||
className="w-full h-full object-cover"
|
size="md"
|
||||||
|
borderClassName="border-pixel-gold/20"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className="text-pixel-gold text-xs font-bold">
|
|
||||||
{username.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, type ChangeEvent } from "react";
|
import { useState, useRef, type ChangeEvent } from "react";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
type CharacterClass =
|
type CharacterClass =
|
||||||
| "WARRIOR"
|
| "WARRIOR"
|
||||||
@@ -242,19 +243,12 @@ export default function ProfileForm({
|
|||||||
{/* Avatar Section */}
|
{/* Avatar Section */}
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="w-32 h-32 rounded-full border-4 border-pixel-gold/50 overflow-hidden bg-gray-900 flex items-center justify-center">
|
<Avatar
|
||||||
{avatar ? (
|
|
||||||
<img
|
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={username}
|
username={username}
|
||||||
className="w-full h-full object-cover"
|
size="2xl"
|
||||||
|
borderClassName="border-4 border-pixel-gold/50"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className="text-pixel-gold text-4xl font-bold">
|
|
||||||
{username.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{uploadingAvatar && (
|
{uploadingAvatar && (
|
||||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
|
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
|
||||||
<div className="text-pixel-gold text-sm">Upload...</div>
|
<div className="text-pixel-gold text-sm">Upload...</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +20,8 @@ interface User {
|
|||||||
|
|
||||||
interface EditingUser {
|
interface EditingUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
username: string | null;
|
||||||
|
avatar: string | null;
|
||||||
hpDelta: number;
|
hpDelta: number;
|
||||||
xpDelta: number;
|
xpDelta: number;
|
||||||
score: number | null;
|
score: number | null;
|
||||||
@@ -32,6 +35,7 @@ export default function UserManagement() {
|
|||||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
|
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@@ -54,6 +58,8 @@ export default function UserManagement() {
|
|||||||
const handleEdit = (user: User) => {
|
const handleEdit = (user: User) => {
|
||||||
setEditingUser({
|
setEditingUser({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
avatar: user.avatar,
|
||||||
hpDelta: 0,
|
hpDelta: 0,
|
||||||
xpDelta: 0,
|
xpDelta: 0,
|
||||||
score: user.score,
|
score: user.score,
|
||||||
@@ -68,6 +74,8 @@ export default function UserManagement() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body: {
|
const body: {
|
||||||
|
username?: string;
|
||||||
|
avatar?: string | null;
|
||||||
hpDelta?: number;
|
hpDelta?: number;
|
||||||
xpDelta?: number;
|
xpDelta?: number;
|
||||||
score?: number;
|
score?: number;
|
||||||
@@ -75,6 +83,12 @@ export default function UserManagement() {
|
|||||||
role?: string;
|
role?: string;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
if (editingUser.username !== null) {
|
||||||
|
body.username = editingUser.username;
|
||||||
|
}
|
||||||
|
if (editingUser.avatar !== undefined) {
|
||||||
|
body.avatar = editingUser.avatar;
|
||||||
|
}
|
||||||
if (editingUser.hpDelta !== 0) {
|
if (editingUser.hpDelta !== 0) {
|
||||||
body.hpDelta = editingUser.hpDelta;
|
body.hpDelta = editingUser.hpDelta;
|
||||||
}
|
}
|
||||||
@@ -170,6 +184,10 @@ export default function UserManagement() {
|
|||||||
const previewXp = isEditing
|
const previewXp = isEditing
|
||||||
? Math.max(0, user.xp + editingUser.xpDelta)
|
? Math.max(0, user.xp + editingUser.xpDelta)
|
||||||
: user.xp;
|
: user.xp;
|
||||||
|
const displayAvatar = isEditing ? editingUser.avatar : user.avatar;
|
||||||
|
const displayUsername = isEditing
|
||||||
|
? editingUser.username || user.username
|
||||||
|
: user.username;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -179,32 +197,17 @@ export default function UserManagement() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-2">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-2">
|
||||||
<div className="flex gap-2 sm:gap-3 items-center flex-1 min-w-0">
|
<div className="flex gap-2 sm:gap-3 items-center flex-1 min-w-0">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full border-2 border-pixel-gold/50 overflow-hidden bg-black/60 flex-shrink-0">
|
<Avatar
|
||||||
{user.avatar ? (
|
src={displayAvatar}
|
||||||
<img
|
username={displayUsername}
|
||||||
src={user.avatar}
|
size="sm"
|
||||||
alt={user.username}
|
className="flex-shrink-0"
|
||||||
className="w-full h-full object-cover"
|
borderClassName="border-2 border-pixel-gold/50"
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.style.display = "none";
|
|
||||||
e.currentTarget.nextElementSibling?.classList.remove(
|
|
||||||
"hidden"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={`w-full h-full flex items-center justify-center text-pixel-gold text-xs sm:text-sm font-bold ${
|
|
||||||
user.avatar ? "hidden" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{user.username.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
|
||||||
<h3 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
|
<h3 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
|
||||||
{user.username}
|
{displayUsername}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
|
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
|
||||||
Niveau {user.level}
|
Niveau {user.level}
|
||||||
@@ -250,6 +253,142 @@ export default function UserManagement() {
|
|||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Username Section */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
|
||||||
|
Nom d'utilisateur
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingUser.username || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingUser({
|
||||||
|
...editingUser,
|
||||||
|
username: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm"
|
||||||
|
placeholder="Nom d'utilisateur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Section */}
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
|
||||||
|
Avatar
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar
|
||||||
|
src={editingUser.avatar}
|
||||||
|
username={editingUser.username || user.username}
|
||||||
|
size="lg"
|
||||||
|
borderClassName="border-2 border-pixel-gold/50"
|
||||||
|
/>
|
||||||
|
{uploadingAvatar === user.id && (
|
||||||
|
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
|
||||||
|
<div className="text-pixel-gold text-xs">
|
||||||
|
Upload...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatars par défaut */}
|
||||||
|
<div className="flex flex-col items-center gap-2 w-full">
|
||||||
|
<label className="block text-pixel-gold text-[10px] sm:text-xs uppercase tracking-widest">
|
||||||
|
Avatars par défaut
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
{[
|
||||||
|
"/avatar-1.jpg",
|
||||||
|
"/avatar-2.jpg",
|
||||||
|
"/avatar-3.jpg",
|
||||||
|
"/avatar-4.jpg",
|
||||||
|
"/avatar-5.jpg",
|
||||||
|
"/avatar-6.jpg",
|
||||||
|
].map((defaultAvatar) => (
|
||||||
|
<button
|
||||||
|
key={defaultAvatar}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setEditingUser({
|
||||||
|
...editingUser,
|
||||||
|
avatar: defaultAvatar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`w-12 h-12 sm:w-14 sm:h-14 rounded-full border-2 overflow-hidden transition ${
|
||||||
|
editingUser.avatar === defaultAvatar
|
||||||
|
? "border-pixel-gold scale-110"
|
||||||
|
: "border-pixel-gold/30 hover:border-pixel-gold/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={defaultAvatar}
|
||||||
|
alt="Avatar par défaut"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Upload */}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploadingAvatar(user.id);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"/api/admin/images/upload",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setEditingUser({
|
||||||
|
...editingUser,
|
||||||
|
avatar: data.url,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert("Erreur lors de l'upload de l'image");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading image:", error);
|
||||||
|
alert("Erreur lors de l'upload de l'image");
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(null);
|
||||||
|
if (e.target) {
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
id={`avatar-upload-${user.id}`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`avatar-upload-${user.id}`}
|
||||||
|
className="px-3 sm:px-4 py-1.5 border border-pixel-gold/50 bg-black/40 text-white uppercase text-[10px] sm:text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition cursor-pointer inline-block"
|
||||||
|
>
|
||||||
|
{uploadingAvatar === user.id
|
||||||
|
? "Upload en cours..."
|
||||||
|
: "Upload un avatar custom"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* HP Section */}
|
{/* HP Section */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Persist database (override DATA_PATH env var to change location)
|
# Persist database (override DATA_PATH env var to change location)
|
||||||
- ${PRISMA_DATA_PATH:-/Volumes/EXTERNAL_USB/sites/got-gaming/data}:/app/data
|
- ${PRISMA_DATA_PATH:-/Volumes/EXTERNAL_USB/sites/got-gaming/data}:/app/data
|
||||||
# Persist uploaded images
|
# Persist uploaded images (avatars and backgrounds)
|
||||||
- ${UPLOADS_PATH:-./uploads}:/app/public/uploads
|
- ${UPLOADS_PATH:-./uploads}:/app/public/uploads
|
||||||
- ./prisma/migrations:/app/prisma/migrations
|
- ./prisma/migrations:/app/prisma/migrations
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
interface Preferences {
|
interface Preferences {
|
||||||
homeBackground: string | null;
|
homeBackground: string | null;
|
||||||
@@ -43,15 +43,29 @@ export function useBackgroundImage(
|
|||||||
) {
|
) {
|
||||||
const { preferences } = usePreferences();
|
const { preferences } = usePreferences();
|
||||||
const [backgroundImage, setBackgroundImage] = useState(defaultImage);
|
const [backgroundImage, setBackgroundImage] = useState(defaultImage);
|
||||||
|
const prevImageRef = useRef<string>(defaultImage);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferences) {
|
if (preferences) {
|
||||||
const imageKey = `${page}Background` as keyof Preferences;
|
const imageKey = `${page}Background` as keyof Preferences;
|
||||||
const customImage = preferences[imageKey];
|
const customImage = preferences[imageKey];
|
||||||
// Utiliser requestAnimationFrame pour éviter les cascades de rendu
|
const targetImage = customImage || defaultImage;
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setBackgroundImage(customImage || defaultImage);
|
// Ne changer que si l'image est vraiment différente
|
||||||
});
|
if (targetImage !== prevImageRef.current) {
|
||||||
|
prevImageRef.current = targetImage;
|
||||||
|
// Précharger l'image avant de changer
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setBackgroundImage(targetImage);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
// Si l'image échoue, utiliser l'image par défaut
|
||||||
|
setBackgroundImage(defaultImage);
|
||||||
|
prevImageRef.current = defaultImage;
|
||||||
|
};
|
||||||
|
img.src = targetImage;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [preferences, page, defaultImage]);
|
}, [preferences, page, defaultImage]);
|
||||||
|
|
||||||
|
|||||||
BIN
public/uploads/backgrounds/got-2.jpg
Normal file
BIN
public/uploads/backgrounds/got-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
public/uploads/backgrounds/got-background.jpg
Normal file
BIN
public/uploads/backgrounds/got-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
public/uploads/backgrounds/leaderboard-bg.jpg
Normal file
BIN
public/uploads/backgrounds/leaderboard-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Reference in New Issue
Block a user