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

This commit is contained in:
Julien Froidefond
2025-12-12 08:46:31 +01:00
parent 3ad680f416
commit ae08ed7793
24 changed files with 1100 additions and 464 deletions

View File

@@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
import ImageSelector from "@/components/ImageSelector";
import UserManagement from "@/components/UserManagement";
import EventManagement from "@/components/EventManagement";
import FeedbackManagement from "@/components/FeedbackManagement";
import BackgroundPreferences from "@/components/BackgroundPreferences";
interface SitePreferences {
id: string;
@@ -22,149 +22,58 @@ type AdminSection = "preferences" | "users" | "events" | "feedbacks";
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
const [activeSection, setActiveSection] =
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 (
<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">
<h1 className="text-2xl sm:text-4xl font-gaming font-black mb-8 text-center break-words">
<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">
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
ADMIN
</span>
</h1>
{/* Navigation Tabs */}
<div className="mb-8">
{/* Mobile: Grid layout */}
<div className="grid grid-cols-2 sm:hidden gap-2">
<button
onClick={() => setActiveSection("preferences")}
className={`px-3 py-2.5 border uppercase text-xs tracking-wider 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"
? "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 whitespace-nowrap ${
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>
<button
onClick={() => setActiveSection("events")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition whitespace-nowrap ${
activeSection === "events"
? "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"
}`}
>
Événements
</button>
<button
onClick={() => setActiveSection("feedbacks")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition whitespace-nowrap ${
activeSection === "feedbacks"
? "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"
}`}
>
Feedbacks
</button>
</div>
<div className="flex gap-4 mb-8 justify-center">
<button
onClick={() => setActiveSection("preferences")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
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>
<button
onClick={() => setActiveSection("events")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "events"
? "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"
}`}
>
Événements
</button>
<button
onClick={() => setActiveSection("feedbacks")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "feedbacks"
? "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"
}`}
>
Feedbacks
</button>
</div>
{activeSection === "preferences" && (
@@ -173,156 +82,13 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
Préférences UI Globales
</h2>
<div className="space-y-4">
<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&apos;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>
<BackgroundPreferences initialPreferences={initialPreferences} />
</div>
</div>
)}
{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">
Gestion des Utilisateurs
</h2>
@@ -331,7 +97,7 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
)}
{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">
Gestion des Événements
</h2>
@@ -340,7 +106,7 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
)}
{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">
Gestion des Feedbacks
</h2>

66
components/Avatar.tsx Normal file
View 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>
);
}

View 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&apos;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>
);
}

View File

@@ -1,10 +1,12 @@
"use client";
import { useBackgroundImage } from "@/hooks/usePreferences";
import Link from "next/link";
export default function HeroSection() {
const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
interface HeroSectionProps {
backgroundImage: string;
}
export default function HeroSection({ backgroundImage }: HeroSectionProps) {
return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24">

View File

@@ -90,7 +90,15 @@ export default function ImageSelector({
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
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

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Avatar from "./Avatar";
interface LeaderboardEntry {
rank: number;
@@ -160,21 +161,12 @@ export default function Leaderboard() {
{/* Avatar and Class */}
<div className="flex items-center gap-6 mb-6">
{selectedEntry.avatar ? (
<div className="w-24 h-24 rounded-full border-4 border-pixel-gold/50 overflow-hidden">
<img
src={selectedEntry.avatar}
alt={selectedEntry.username}
className="w-full h-full object-cover"
/>
</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>
)}
<Avatar
src={selectedEntry.avatar}
username={selectedEntry.username}
size="xl"
borderClassName="border-4 border-pixel-gold/50"
/>
<div>
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
Rank #{selectedEntry.rank}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Avatar from "./Avatar";
interface LeaderboardEntry {
rank: number;
@@ -105,28 +106,13 @@ export default function LeaderboardSection({
{/* Player */}
<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">
{entry.avatar ? (
<img
src={entry.avatar}
alt={entry.username}
className="w-full h-full object-cover"
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-[10px] sm:text-xs font-bold ${
entry.avatar ? "hidden" : ""
}`}
>
{entry.username.charAt(0).toUpperCase()}
</div>
</div>
<Avatar
src={entry.avatar}
username={entry.username}
size="sm"
className="flex-shrink-0"
borderClassName="border-pixel-gold/30"
/>
<div
className="flex items-center gap-1 sm:gap-2 cursor-pointer hover:opacity-80 transition min-w-0"
onClick={() => setSelectedEntry(entry)}
@@ -213,28 +199,13 @@ export default function LeaderboardSection({
{/* Avatar and Class */}
<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">
{selectedEntry.avatar ? (
<img
src={selectedEntry.avatar}
alt={selectedEntry.username}
className="w-full h-full object-cover"
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-2xl sm:text-4xl font-bold ${
selectedEntry.avatar ? "hidden" : ""
}`}
>
{selectedEntry.username.charAt(0).toUpperCase()}
</div>
</div>
<Avatar
src={selectedEntry.avatar}
username={selectedEntry.username}
size="lg"
className="flex-shrink-0"
borderClassName="border-2 sm:border-4 border-pixel-gold/50"
/>
<div>
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
Rank #{selectedEntry.rank}

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import Avatar from "./Avatar";
interface UserData {
username: string;
@@ -140,19 +141,12 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
href="/profile"
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 ? (
<img
src={avatar}
alt={username}
className="w-full h-full object-cover"
/>
) : (
<span className="text-pixel-gold text-xs font-bold">
{username.charAt(0).toUpperCase()}
</span>
)}
</div>
<Avatar
src={avatar}
username={username}
size="md"
borderClassName="border-pixel-gold/20"
/>
</Link>
{/* Stats */}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, type ChangeEvent } from "react";
import Avatar from "./Avatar";
type CharacterClass =
| "WARRIOR"
@@ -242,19 +243,12 @@ export default function ProfileForm({
{/* Avatar Section */}
<div className="flex flex-col items-center gap-4">
<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 ? (
<img
src={avatar}
alt={username}
className="w-full h-full object-cover"
/>
) : (
<span className="text-pixel-gold text-4xl font-bold">
{username.charAt(0).toUpperCase()}
</span>
)}
</div>
<Avatar
src={avatar}
username={username}
size="2xl"
borderClassName="border-4 border-pixel-gold/50"
/>
{uploadingAvatar && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
<div className="text-pixel-gold text-sm">Upload...</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import Avatar from "./Avatar";
interface User {
id: string;
@@ -19,6 +20,8 @@ interface User {
interface EditingUser {
userId: string;
username: string | null;
avatar: string | null;
hpDelta: number;
xpDelta: number;
score: number | null;
@@ -32,6 +35,7 @@ export default function UserManagement() {
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [uploadingAvatar, setUploadingAvatar] = useState<string | null>(null);
useEffect(() => {
fetchUsers();
@@ -54,6 +58,8 @@ export default function UserManagement() {
const handleEdit = (user: User) => {
setEditingUser({
userId: user.id,
username: user.username,
avatar: user.avatar,
hpDelta: 0,
xpDelta: 0,
score: user.score,
@@ -68,6 +74,8 @@ export default function UserManagement() {
setSaving(true);
try {
const body: {
username?: string;
avatar?: string | null;
hpDelta?: number;
xpDelta?: number;
score?: number;
@@ -75,6 +83,12 @@ export default function UserManagement() {
role?: string;
} = {};
if (editingUser.username !== null) {
body.username = editingUser.username;
}
if (editingUser.avatar !== undefined) {
body.avatar = editingUser.avatar;
}
if (editingUser.hpDelta !== 0) {
body.hpDelta = editingUser.hpDelta;
}
@@ -170,6 +184,10 @@ export default function UserManagement() {
const previewXp = isEditing
? Math.max(0, user.xp + editingUser.xpDelta)
: user.xp;
const displayAvatar = isEditing ? editingUser.avatar : user.avatar;
const displayUsername = isEditing
? editingUser.username || user.username
: user.username;
return (
<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 gap-2 sm:gap-3 items-center flex-1 min-w-0">
{/* 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">
{user.avatar ? (
<img
src={user.avatar}
alt={user.username}
className="w-full h-full object-cover"
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>
<Avatar
src={displayAvatar}
username={displayUsername}
size="sm"
className="flex-shrink-0"
borderClassName="border-2 border-pixel-gold/50"
/>
<div className="flex-1 min-w-0">
<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">
{user.username}
{displayUsername}
</h3>
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
Niveau {user.level}
@@ -250,6 +253,142 @@ export default function UserManagement() {
{isEditing ? (
<div className="space-y-4">
{/* Username Section */}
<div>
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
Nom d&apos;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 */}
<div>
<div className="flex justify-between items-center mb-2">