Refactor admin preferences management to use global site preferences, update UI components for better user experience, and implement image selection for background settings.

This commit is contained in:
Julien Froidefond
2025-12-09 08:37:52 +01:00
parent 4486f305f2
commit 8c326bdd20
21 changed files with 1853 additions and 199 deletions

View File

@@ -4,26 +4,25 @@ import { useEffect, useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/Navigation";
import ImageSelector from "@/components/ImageSelector";
interface UserPreferences { interface SitePreferences {
id: string; id: string;
userId: string;
homeBackground: string | null; homeBackground: string | null;
eventsBackground: string | null; eventsBackground: string | null;
leaderboardBackground: string | null; leaderboardBackground: string | null;
user: {
id: string;
username: string;
email: string;
};
} }
type AdminSection = "preferences" | "users";
export default function AdminPage() { export default function AdminPage() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const router = useRouter(); const router = useRouter();
const [preferences, setPreferences] = useState<UserPreferences[]>([]); const [activeSection, setActiveSection] =
useState<AdminSection>("preferences");
const [preferences, setPreferences] = useState<SitePreferences | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingUserId, setEditingUserId] = useState<string | null>(null); const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
homeBackground: "", homeBackground: "",
eventsBackground: "", eventsBackground: "",
@@ -52,6 +51,11 @@ export default function AdminPage() {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setPreferences(data); setPreferences(data);
setFormData({
homeBackground: data.homeBackground || "",
eventsBackground: data.eventsBackground || "",
leaderboardBackground: data.leaderboardBackground || "",
});
} }
} catch (error) { } catch (error) {
console.error("Error fetching preferences:", error); console.error("Error fetching preferences:", error);
@@ -60,38 +64,23 @@ export default function AdminPage() {
} }
}; };
const handleEdit = (pref: UserPreferences) => { const handleEdit = () => {
setEditingUserId(pref.userId); setIsEditing(true);
setFormData({
homeBackground: pref.homeBackground || "",
eventsBackground: pref.eventsBackground || "",
leaderboardBackground: pref.leaderboardBackground || "",
});
}; };
const handleSave = async () => { const handleSave = async () => {
if (!editingUserId) return;
try { try {
const response = await fetch("/api/admin/preferences", { const response = await fetch("/api/admin/preferences", {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(formData),
userId: editingUserId,
...formData,
}),
}); });
if (response.ok) { if (response.ok) {
await fetchPreferences(); await fetchPreferences();
setEditingUserId(null); setIsEditing(false);
setFormData({
homeBackground: "",
eventsBackground: "",
leaderboardBackground: "",
});
} }
} catch (error) { } catch (error) {
console.error("Error updating preferences:", error); console.error("Error updating preferences:", error);
@@ -99,12 +88,14 @@ export default function AdminPage() {
}; };
const handleCancel = () => { const handleCancel = () => {
setEditingUserId(null); setIsEditing(false);
setFormData({ if (preferences) {
homeBackground: "", setFormData({
eventsBackground: "", homeBackground: preferences.homeBackground || "",
leaderboardBackground: "", eventsBackground: preferences.eventsBackground || "",
}); leaderboardBackground: preferences.leaderboardBackground || "",
});
}
}; };
if (status === "loading" || loading) { if (status === "loading" || loading) {
@@ -125,27 +116,53 @@ export default function AdminPage() {
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16"> <div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
<h1 className="text-4xl font-gaming font-black mb-8 text-center"> <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 - GESTION DES PRÉFÉRENCES UI ADMIN
</span> </span>
</h1> </h1>
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm"> {/* Navigation Tabs */}
<div className="space-y-4"> <div className="flex gap-4 mb-8 justify-center">
{preferences.map((pref) => ( <button
<div onClick={() => setActiveSection("preferences")}
key={pref.id} className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
className="bg-black/60 border border-pixel-gold/20 rounded p-4" activeSection === "preferences"
> ? "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"
}`}
>
Préférences UI
</button>
<button
onClick={() => setActiveSection("users")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "users"
? "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"
}`}
>
Utilisateurs
</button>
</div>
{activeSection === "preferences" && (
<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">
Préférences UI Globales
</h2>
<div className="space-y-4">
<div className="bg-black/60 border border-pixel-gold/20 rounded p-4">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<h3 className="text-pixel-gold font-bold text-lg"> <h3 className="text-pixel-gold font-bold text-lg">
{pref.user.username} Images de fond du site
</h3> </h3>
<p className="text-gray-400 text-sm">{pref.user.email}</p> <p className="text-gray-400 text-sm">
Ces préférences s'appliquent à tous les utilisateurs
</p>
</div> </div>
{editingUserId !== pref.userId && ( {!isEditing && (
<button <button
onClick={() => handleEdit(pref)} onClick={handleEdit}
className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition" className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition"
> >
Modifier Modifier
@@ -153,60 +170,39 @@ export default function AdminPage() {
)} )}
</div> </div>
{editingUserId === pref.userId ? ( {isEditing ? (
<div className="space-y-3"> <div className="space-y-6">
<div> <ImageSelector
<label className="block text-sm text-gray-300 mb-1"> value={formData.homeBackground}
Background Home onChange={(url) =>
</label> setFormData({
<input ...formData,
type="text" homeBackground: url,
value={formData.homeBackground} })
onChange={(e) => }
setFormData({ label="Background Home"
...formData, />
homeBackground: e.target.value, <ImageSelector
}) value={formData.eventsBackground}
} onChange={(url) =>
placeholder="/got-2.jpg" setFormData({
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm" ...formData,
/> eventsBackground: url,
</div> })
<div> }
<label className="block text-sm text-gray-300 mb-1"> label="Background Events"
Background Events />
</label> <ImageSelector
<input value={formData.leaderboardBackground}
type="text" onChange={(url) =>
value={formData.eventsBackground} setFormData({
onChange={(e) => ...formData,
setFormData({ leaderboardBackground: url,
...formData, })
eventsBackground: e.target.value, }
}) label="Background Leaderboard"
} />
placeholder="/got-2.jpg" <div className="flex gap-2 pt-4">
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1">
Background Leaderboard
</label>
<input
type="text"
value={formData.leaderboardBackground}
onChange={(e) =>
setFormData({
...formData,
leaderboardBackground: e.target.value,
})
}
placeholder="/leaderboard-bg.jpg"
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
<div className="flex gap-2">
<button <button
onClick={handleSave} 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" 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"
@@ -224,30 +220,34 @@ export default function AdminPage() {
) : ( ) : (
<div className="space-y-2 text-sm text-gray-400"> <div className="space-y-2 text-sm text-gray-400">
<div> <div>
Home: {pref.homeBackground || "Par défaut"} Home: {preferences?.homeBackground || "Par défaut"}
</div> </div>
<div> <div>
Events: {pref.eventsBackground || "Par défaut"} Events: {preferences?.eventsBackground || "Par défaut"}
</div> </div>
<div> <div>
Leaderboard:{" "} Leaderboard:{" "}
{pref.leaderboardBackground || "Par défaut"} {preferences?.leaderboardBackground || "Par défaut"}
</div> </div>
</div> </div>
)} )}
</div> </div>
))} </div>
{preferences.length === 0 && (
<div className="text-center text-gray-400 py-8">
Aucune préférence trouvée
</div>
)}
</div> </div>
</div> )}
{activeSection === "users" && (
<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">
Gestion des Utilisateurs
</h2>
<div className="text-center text-gray-400 py-8">
Section utilisateurs à venir...
</div>
</div>
)}
</div> </div>
</section> </section>
</main> </main>
); );
} }

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { Role } from "@/prisma/generated/prisma/client";
import { readdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function GET() {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const images: string[] = [];
// Lister les images dans public/
const publicDir = join(process.cwd(), "public");
if (existsSync(publicDir)) {
const files = await readdir(publicDir);
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}`));
}
return NextResponse.json({ images });
} catch (error) {
console.error("Error listing images:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des images" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { Role } from "@/prisma/generated/prisma/client";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "Aucun fichier fourni" }, { status: 400 });
}
// Vérifier le type de fichier
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: "Le fichier doit être une image" },
{ status: 400 }
);
}
// Créer le dossier uploads s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads");
if (!existsSync(uploadsDir)) {
await mkdir(uploadsDir, { recursive: true });
}
// Générer un nom de fichier unique
const timestamp = Date.now();
const filename = `${timestamp}-${file.name}`;
const filepath = join(uploadsDir, filename);
// Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
// Retourner l'URL de l'image
const imageUrl = `/uploads/${filename}`;
return NextResponse.json({ url: imageUrl });
} catch (error) {
console.error("Error uploading image:", error);
return NextResponse.json(
{ error: "Erreur lors de l'upload de l'image" },
{ status: 500 }
);
}
}

View File

@@ -11,20 +11,24 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
// Récupérer toutes les préférences utilisateur // Récupérer les préférences globales du site
const preferences = await prisma.userPreferences.findMany({ let sitePreferences = await prisma.sitePreferences.findUnique({
include: { where: { id: "global" },
user: {
select: {
id: true,
username: true,
email: true,
},
},
},
}); });
return NextResponse.json(preferences); // Si elles n'existent pas, créer une entrée par défaut
if (!sitePreferences) {
sitePreferences = await prisma.sitePreferences.create({
data: {
id: "global",
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
},
});
}
return NextResponse.json(sitePreferences);
} catch (error) { } catch (error) {
console.error("Error fetching admin preferences:", error); console.error("Error fetching admin preferences:", error);
return NextResponse.json( return NextResponse.json(
@@ -43,25 +47,27 @@ export async function PUT(request: Request) {
} }
const body = await request.json(); const body = await request.json();
const { userId, homeBackground, eventsBackground, leaderboardBackground } = const { homeBackground, eventsBackground, leaderboardBackground } = body;
body;
if (!userId) { const preferences = await prisma.sitePreferences.upsert({
return NextResponse.json({ error: "userId requis" }, { status: 400 }); where: { id: "global" },
}
const preferences = await prisma.userPreferences.upsert({
where: { userId },
update: { update: {
homeBackground: homeBackground ?? undefined, homeBackground:
eventsBackground: eventsBackground ?? undefined, homeBackground === "" ? null : homeBackground ?? undefined,
leaderboardBackground: leaderboardBackground ?? undefined, eventsBackground:
eventsBackground === "" ? null : eventsBackground ?? undefined,
leaderboardBackground:
leaderboardBackground === ""
? null
: leaderboardBackground ?? undefined,
}, },
create: { create: {
userId, id: "global",
homeBackground: homeBackground ?? null, homeBackground: homeBackground === "" ? null : homeBackground ?? null,
eventsBackground: eventsBackground ?? null, eventsBackground:
leaderboardBackground: leaderboardBackground ?? null, eventsBackground === "" ? null : eventsBackground ?? null,
leaderboardBackground:
leaderboardBackground === "" ? null : leaderboardBackground ?? null,
}, },
}); });

View File

@@ -1,65 +1,36 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() { export async function GET() {
try { try {
const session = await auth(); // Récupérer les préférences globales du site (pas besoin d'authentification)
let sitePreferences = await prisma.sitePreferences.findUnique({
if (!session?.user) { where: { id: "global" },
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const preferences = await prisma.userPreferences.findUnique({
where: { userId: session.user.id },
}); });
return NextResponse.json(preferences || {}); // Si elles n'existent pas, retourner des valeurs par défaut
if (!sitePreferences) {
return NextResponse.json({
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
});
}
return NextResponse.json({
homeBackground: sitePreferences.homeBackground,
eventsBackground: sitePreferences.eventsBackground,
leaderboardBackground: sitePreferences.leaderboardBackground,
});
} catch (error) { } catch (error) {
console.error("Error fetching preferences:", error); console.error("Error fetching preferences:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erreur lors de la récupération des préférences" }, {
{ status: 500 } homeBackground: null,
); eventsBackground: null,
} leaderboardBackground: null,
} },
{ status: 200 }
export async function PUT(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const body = await request.json();
const { homeBackground, eventsBackground, leaderboardBackground, theme } =
body;
const preferences = await prisma.userPreferences.upsert({
where: { userId: session.user.id },
update: {
homeBackground: homeBackground ?? undefined,
eventsBackground: eventsBackground ?? undefined,
leaderboardBackground: leaderboardBackground ?? undefined,
theme: theme ?? undefined,
},
create: {
userId: session.user.id,
homeBackground: homeBackground ?? null,
eventsBackground: eventsBackground ?? null,
leaderboardBackground: leaderboardBackground ?? null,
theme: theme ?? "default",
},
});
return NextResponse.json(preferences);
} catch (error) {
console.error("Error updating preferences:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour des préférences" },
{ status: 500 }
); );
} }
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useBackgroundImage } from "@/hooks/usePreferences";
interface Event { interface Event {
id: string; id: string;
@@ -67,6 +68,7 @@ const getStatusBadge = (status: Event["status"]) => {
export default function EventsPageSection() { export default function EventsPageSection() {
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const backgroundImage = useBackgroundImage("events", "/got-2.jpg");
useEffect(() => { useEffect(() => {
fetch("/api/events") fetch("/api/events")
@@ -94,7 +96,7 @@ export default function EventsPageSection() {
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: `url('/got-2.jpg')`, backgroundImage: `url('${backgroundImage}')`,
}} }}
> >
{/* Dark overlay for readability */} {/* Dark overlay for readability */}

View File

@@ -1,13 +1,17 @@
"use client"; "use client";
import { useBackgroundImage } from "@/hooks/usePreferences";
export default function HeroSection() { export default function HeroSection() {
const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
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">
{/* Background Image */} {/* Background Image */}
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: `url('/got-2.jpg')`, backgroundImage: `url('${backgroundImage}')`,
}} }}
> >
{/* Dark overlay for readability */} {/* Dark overlay for readability */}

View File

@@ -0,0 +1,194 @@
"use client";
import { useState, useEffect, useRef } from "react";
interface ImageSelectorProps {
value: string;
onChange: (url: string) => void;
label: string;
}
export default function ImageSelector({
value,
onChange,
label,
}: ImageSelectorProps) {
const [availableImages, setAvailableImages] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [urlInput, setUrlInput] = useState("");
const [showGallery, setShowGallery] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchAvailableImages();
}, []);
const fetchAvailableImages = async () => {
try {
const response = await fetch("/api/admin/images/list");
if (response.ok) {
const data = await response.json();
setAvailableImages(data.images || []);
}
} catch (error) {
console.error("Error fetching images:", error);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
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();
onChange(data.url);
await fetchAvailableImages(); // Rafraîchir la liste
} 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 {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleUrlSubmit = () => {
if (urlInput.trim()) {
onChange(urlInput.trim());
setUrlInput("");
}
};
return (
<div className="space-y-3">
<label className="block text-sm text-gray-300 mb-1">{label}</label>
{/* Prévisualisation */}
{value && (
<div className="mb-3">
<div className="relative w-full h-48 border border-pixel-gold/30 rounded overflow-hidden bg-black/60">
<img
src={value}
alt="Preview"
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg"; // Image par défaut en cas d'erreur
}}
/>
<button
onClick={() => onChange("")}
className="absolute top-2 right-2 px-2 py-1 bg-red-900/80 text-red-200 text-xs rounded hover:bg-red-900 transition"
>
</button>
</div>
<p className="text-xs text-gray-400 mt-1 truncate">{value}</p>
</div>
)}
{/* Input URL */}
<div className="flex gap-2">
<input
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleUrlSubmit()}
placeholder="https://example.com/image.jpg ou /image.jpg"
className="flex-1 px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
<button
onClick={handleUrlSubmit}
className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition"
>
URL
</button>
</div>
{/* Upload depuis le disque */}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
id={`file-${label}`}
/>
<label
htmlFor={`file-${label}`}
className={`flex-1 px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition text-center cursor-pointer ${
uploading ? "opacity-50 cursor-not-allowed" : ""
}`}
>
{uploading ? "Upload..." : "Upload depuis le disque"}
</label>
<button
onClick={() => setShowGallery(!showGallery)}
className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition"
>
{showGallery ? "Masquer" : "Galerie"}
</button>
</div>
{/* Galerie d'images */}
{showGallery && (
<div className="mt-4 p-4 bg-black/40 border border-pixel-gold/20 rounded">
<h4 className="text-sm text-gray-300 mb-3">Images disponibles</h4>
<div className="grid grid-cols-3 md:grid-cols-4 gap-3 max-h-64 overflow-y-auto">
{availableImages.length === 0 ? (
<div className="col-span-full text-center text-gray-400 text-sm py-4">
Aucune image disponible
</div>
) : (
availableImages.map((imageUrl) => (
<button
key={imageUrl}
onClick={() => {
onChange(imageUrl);
setShowGallery(false);
}}
className={`relative aspect-video rounded overflow-hidden border-2 transition ${
value === imageUrl
? "border-pixel-gold"
: "border-pixel-gold/30 hover:border-pixel-gold/50"
}`}
>
<img
src={imageUrl}
alt={imageUrl}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
{value === imageUrl && (
<div className="absolute inset-0 bg-pixel-gold/20 flex items-center justify-center">
<span className="text-pixel-gold text-xs"></span>
</div>
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useBackgroundImage } from "@/hooks/usePreferences";
interface LeaderboardEntry { interface LeaderboardEntry {
rank: number; rank: number;
@@ -18,6 +19,10 @@ const formatScore = (score: number): string => {
export default function LeaderboardSection() { export default function LeaderboardSection() {
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]); const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const backgroundImage = useBackgroundImage(
"leaderboard",
"/leaderboard-bg.jpg"
);
useEffect(() => { useEffect(() => {
fetch("/api/leaderboard") fetch("/api/leaderboard")
@@ -45,7 +50,7 @@ export default function LeaderboardSection() {
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: `url('/leaderboard-bg.jpg')`, backgroundImage: `url('${backgroundImage}')`,
}} }}
> >
{/* Dark overlay for readability */} {/* Dark overlay for readability */}

56
hooks/usePreferences.ts Normal file
View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
interface Preferences {
homeBackground: string | null;
eventsBackground: string | null;
leaderboardBackground: string | null;
}
export function usePreferences() {
const [preferences, setPreferences] = useState<Preferences | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Les préférences sont maintenant globales, pas besoin d'authentification
fetch("/api/preferences")
.then((res) => res.json())
.then((data) => {
setPreferences(
data || {
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
}
);
setLoading(false);
})
.catch(() => {
setPreferences({
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
});
setLoading(false);
});
}, []);
return { preferences, loading };
}
export function useBackgroundImage(
page: "home" | "events" | "leaderboard",
defaultImage: string
) {
const { preferences } = usePreferences();
const [backgroundImage, setBackgroundImage] = useState(defaultImage);
useEffect(() => {
if (preferences) {
const imageKey = `${page}Background` as keyof Preferences;
const customImage = preferences[imageKey];
setBackgroundImage(customImage || defaultImage);
}
}, [preferences, page, defaultImage]);
return backgroundImage;
}

View File

@@ -32,3 +32,8 @@ export type UserPreferences = Prisma.UserPreferencesModel
* *
*/ */
export type Event = Prisma.EventModel export type Event = Prisma.EventModel
/**
* Model SitePreferences
*
*/
export type SitePreferences = Prisma.SitePreferencesModel

View File

@@ -54,3 +54,8 @@ export type UserPreferences = Prisma.UserPreferencesModel
* *
*/ */
export type Event = Prisma.EventModel export type Event = Prisma.EventModel
/**
* Model SitePreferences
*
*/
export type SitePreferences = Prisma.SitePreferencesModel

View File

@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
"clientVersion": "7.1.0", "clientVersion": "7.1.0",
"engineVersion": "ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba", "engineVersion": "ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
"activeProvider": "sqlite", "activeProvider": "sqlite",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"./generated/prisma\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nenum Role {\n USER\n ADMIN\n}\n\nenum EventType {\n SUMMIT\n LAUNCH\n FESTIVAL\n COMPETITION\n}\n\nenum EventStatus {\n UPCOMING\n LIVE\n PAST\n}\n\nmodel User {\n id String @id @default(cuid())\n email String @unique\n password String\n username String @unique\n role Role @default(USER)\n score Int @default(0)\n level Int @default(1)\n hp Int @default(1000)\n maxHp Int @default(1000)\n xp Int @default(0)\n maxXp Int @default(5000)\n avatar String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n preferences UserPreferences?\n\n @@index([score])\n @@index([email])\n}\n\nmodel UserPreferences {\n id String @id @default(cuid())\n userId String @unique\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n // Background images for each page\n homeBackground String?\n eventsBackground String?\n leaderboardBackground String?\n\n // Other UI preferences can be added here\n theme String? @default(\"default\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Event {\n id String @id @default(cuid())\n date String\n name String\n description String\n type EventType\n status EventStatus\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([status])\n @@index([date])\n}\n", "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"./generated/prisma\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nenum Role {\n USER\n ADMIN\n}\n\nenum EventType {\n SUMMIT\n LAUNCH\n FESTIVAL\n COMPETITION\n}\n\nenum EventStatus {\n UPCOMING\n LIVE\n PAST\n}\n\nmodel User {\n id String @id @default(cuid())\n email String @unique\n password String\n username String @unique\n role Role @default(USER)\n score Int @default(0)\n level Int @default(1)\n hp Int @default(1000)\n maxHp Int @default(1000)\n xp Int @default(0)\n maxXp Int @default(5000)\n avatar String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n preferences UserPreferences?\n\n @@index([score])\n @@index([email])\n}\n\nmodel UserPreferences {\n id String @id @default(cuid())\n userId String @unique\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n // Background images for each page\n homeBackground String?\n eventsBackground String?\n leaderboardBackground String?\n\n // Other UI preferences can be added here\n theme String? @default(\"default\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Event {\n id String @id @default(cuid())\n date String\n name String\n description String\n type EventType\n status EventStatus\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([status])\n @@index([date])\n}\n\nmodel SitePreferences {\n id String @id @default(\"global\")\n homeBackground String?\n eventsBackground String?\n leaderboardBackground String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
"runtimeDataModel": { "runtimeDataModel": {
"models": {}, "models": {},
"enums": {}, "enums": {},
@@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = {
} }
} }
config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"Role\"},{\"name\":\"score\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"level\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"hp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"maxHp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"xp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"maxXp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"avatar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"preferences\",\"kind\":\"object\",\"type\":\"UserPreferences\",\"relationName\":\"UserToUserPreferences\"}],\"dbName\":null},\"UserPreferences\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserPreferences\"},{\"name\":\"homeBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"eventsBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"leaderboardBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"theme\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Event\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"date\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"enum\",\"type\":\"EventType\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"EventStatus\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"Role\"},{\"name\":\"score\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"level\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"hp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"maxHp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"xp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"maxXp\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"avatar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"preferences\",\"kind\":\"object\",\"type\":\"UserPreferences\",\"relationName\":\"UserToUserPreferences\"}],\"dbName\":null},\"UserPreferences\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserPreferences\"},{\"name\":\"homeBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"eventsBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"leaderboardBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"theme\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Event\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"date\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"enum\",\"type\":\"EventType\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"EventStatus\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"SitePreferences\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"homeBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"eventsBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"leaderboardBackground\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> { async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {
const { Buffer } = await import('node:buffer') const { Buffer } = await import('node:buffer')
@@ -203,6 +203,16 @@ export interface PrismaClient<
* ``` * ```
*/ */
get event(): Prisma.EventDelegate<ExtArgs, { omit: OmitOpts }>; get event(): Prisma.EventDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.sitePreferences`: Exposes CRUD operations for the **SitePreferences** model.
* Example usage:
* ```ts
* // Fetch zero or more SitePreferences
* const sitePreferences = await prisma.sitePreferences.findMany()
* ```
*/
get sitePreferences(): Prisma.SitePreferencesDelegate<ExtArgs, { omit: OmitOpts }>;
} }
export function getPrismaClientClass(): PrismaClientConstructor { export function getPrismaClientClass(): PrismaClientConstructor {

View File

@@ -386,7 +386,8 @@ type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRe
export const ModelName = { export const ModelName = {
User: 'User', User: 'User',
UserPreferences: 'UserPreferences', UserPreferences: 'UserPreferences',
Event: 'Event' Event: 'Event',
SitePreferences: 'SitePreferences'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -402,7 +403,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "userPreferences" | "event" modelProps: "user" | "userPreferences" | "event" | "sitePreferences"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -628,6 +629,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
SitePreferences: {
payload: Prisma.$SitePreferencesPayload<ExtArgs>
fields: Prisma.SitePreferencesFieldRefs
operations: {
findUnique: {
args: Prisma.SitePreferencesFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload> | null
}
findUniqueOrThrow: {
args: Prisma.SitePreferencesFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>
}
findFirst: {
args: Prisma.SitePreferencesFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload> | null
}
findFirstOrThrow: {
args: Prisma.SitePreferencesFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>
}
findMany: {
args: Prisma.SitePreferencesFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>[]
}
create: {
args: Prisma.SitePreferencesCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>
}
createMany: {
args: Prisma.SitePreferencesCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SitePreferencesCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>[]
}
delete: {
args: Prisma.SitePreferencesDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>
}
update: {
args: Prisma.SitePreferencesUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>
}
deleteMany: {
args: Prisma.SitePreferencesDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SitePreferencesUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SitePreferencesUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>[]
}
upsert: {
args: Prisma.SitePreferencesUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SitePreferencesPayload>
}
aggregate: {
args: Prisma.SitePreferencesAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSitePreferences>
}
groupBy: {
args: Prisma.SitePreferencesGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SitePreferencesGroupByOutputType>[]
}
count: {
args: Prisma.SitePreferencesCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SitePreferencesCountAggregateOutputType> | number
}
}
}
} }
} & { } & {
other: { other: {
@@ -712,6 +787,18 @@ export const EventScalarFieldEnum = {
export type EventScalarFieldEnum = (typeof EventScalarFieldEnum)[keyof typeof EventScalarFieldEnum] export type EventScalarFieldEnum = (typeof EventScalarFieldEnum)[keyof typeof EventScalarFieldEnum]
export const SitePreferencesScalarFieldEnum = {
id: 'id',
homeBackground: 'homeBackground',
eventsBackground: 'eventsBackground',
leaderboardBackground: 'leaderboardBackground',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SitePreferencesScalarFieldEnum = (typeof SitePreferencesScalarFieldEnum)[keyof typeof SitePreferencesScalarFieldEnum]
export const SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -880,6 +967,7 @@ export type GlobalOmitConfig = {
user?: Prisma.UserOmit user?: Prisma.UserOmit
userPreferences?: Prisma.UserPreferencesOmit userPreferences?: Prisma.UserPreferencesOmit
event?: Prisma.EventOmit event?: Prisma.EventOmit
sitePreferences?: Prisma.SitePreferencesOmit
} }
/* Types for Logging */ /* Types for Logging */

View File

@@ -53,7 +53,8 @@ export const AnyNull = runtime.AnyNull
export const ModelName = { export const ModelName = {
User: 'User', User: 'User',
UserPreferences: 'UserPreferences', UserPreferences: 'UserPreferences',
Event: 'Event' Event: 'Event',
SitePreferences: 'SitePreferences'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -117,6 +118,18 @@ export const EventScalarFieldEnum = {
export type EventScalarFieldEnum = (typeof EventScalarFieldEnum)[keyof typeof EventScalarFieldEnum] export type EventScalarFieldEnum = (typeof EventScalarFieldEnum)[keyof typeof EventScalarFieldEnum]
export const SitePreferencesScalarFieldEnum = {
id: 'id',
homeBackground: 'homeBackground',
eventsBackground: 'eventsBackground',
leaderboardBackground: 'leaderboardBackground',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SitePreferencesScalarFieldEnum = (typeof SitePreferencesScalarFieldEnum)[keyof typeof SitePreferencesScalarFieldEnum]
export const SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'

View File

@@ -11,4 +11,5 @@
export type * from './models/User' export type * from './models/User'
export type * from './models/UserPreferences' export type * from './models/UserPreferences'
export type * from './models/Event' export type * from './models/Event'
export type * from './models/SitePreferences'
export type * from './commonInputTypes' export type * from './commonInputTypes'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "SitePreferences" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'global',
"homeBackground" TEXT,
"eventsBackground" TEXT,
"leaderboardBackground" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@@ -79,3 +79,12 @@ model Event {
@@index([status]) @@index([status])
@@index([date]) @@index([date])
} }
model SitePreferences {
id String @id @default("global")
homeBackground String?
eventsBackground String?
leaderboardBackground String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

0
public/uploads/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB