Refactor component imports and structure: Update import paths for various components to improve organization, moving them into appropriate subdirectories. Remove unused components related to user and event management, enhancing code clarity and maintainability across the application.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m36s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m36s
This commit is contained in:
120
components/admin/AdminPanel.tsx
Normal file
120
components/admin/AdminPanel.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import UserManagement from "@/components/admin/UserManagement";
|
||||
import EventManagement from "@/components/admin/EventManagement";
|
||||
import FeedbackManagement from "@/components/admin/FeedbackManagement";
|
||||
import BackgroundPreferences from "@/components/admin/BackgroundPreferences";
|
||||
import { Button, Card, SectionTitle } from "@/components/ui";
|
||||
|
||||
interface SitePreferences {
|
||||
id: string;
|
||||
homeBackground: string | null;
|
||||
eventsBackground: string | null;
|
||||
leaderboardBackground: string | null;
|
||||
}
|
||||
|
||||
interface AdminPanelProps {
|
||||
initialPreferences: SitePreferences;
|
||||
}
|
||||
|
||||
type AdminSection = "preferences" | "users" | "events" | "feedbacks";
|
||||
|
||||
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<AdminSection>("preferences");
|
||||
|
||||
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-8 py-16">
|
||||
<SectionTitle variant="gradient" size="md" className="mb-8 text-center">
|
||||
ADMIN
|
||||
</SectionTitle>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-4 mb-8 justify-center flex-wrap">
|
||||
<Button
|
||||
onClick={() => setActiveSection("preferences")}
|
||||
variant={activeSection === "preferences" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={
|
||||
activeSection === "preferences" ? "bg-pixel-gold/10" : ""
|
||||
}
|
||||
>
|
||||
Préférences UI
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveSection("users")}
|
||||
variant={activeSection === "users" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={activeSection === "users" ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
Utilisateurs
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveSection("events")}
|
||||
variant={activeSection === "events" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={activeSection === "events" ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
Événements
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveSection("feedbacks")}
|
||||
variant={activeSection === "feedbacks" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={activeSection === "feedbacks" ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
Feedbacks
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeSection === "preferences" && (
|
||||
<Card variant="dark" className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<h2 className="text-xl sm:text-2xl font-gaming font-bold text-pixel-gold break-words">
|
||||
Préférences UI Globales
|
||||
</h2>
|
||||
<Link href="/admin/style-guide" target="_blank">
|
||||
<Button variant="primary" size="sm">
|
||||
📖 Voir le Style Guide
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<BackgroundPreferences initialPreferences={initialPreferences} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === "users" && (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Utilisateurs
|
||||
</h2>
|
||||
<UserManagement />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === "events" && (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Événements
|
||||
</h2>
|
||||
<EventManagement />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === "feedbacks" && (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Feedbacks
|
||||
</h2>
|
||||
<FeedbackManagement />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
383
components/admin/BackgroundPreferences.tsx
Normal file
383
components/admin/BackgroundPreferences.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import ImageSelector from "@/components/layout/ImageSelector";
|
||||
import { updateSitePreferences } from "@/actions/admin/preferences";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
|
||||
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) {
|
||||
// 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 [preferences, setPreferences] = useState<SitePreferences | null>(
|
||||
initialPreferences
|
||||
);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const computedFormData = useMemo(
|
||||
() => ({
|
||||
homeBackground: getFormValue(
|
||||
initialPreferences.homeBackground,
|
||||
DEFAULT_IMAGES.home
|
||||
),
|
||||
eventsBackground: getFormValue(
|
||||
initialPreferences.eventsBackground,
|
||||
DEFAULT_IMAGES.events
|
||||
),
|
||||
leaderboardBackground: getFormValue(
|
||||
initialPreferences.leaderboardBackground,
|
||||
DEFAULT_IMAGES.leaderboard
|
||||
),
|
||||
}),
|
||||
[initialPreferences]
|
||||
);
|
||||
|
||||
const [formData, setFormData] = useState(computedFormData);
|
||||
|
||||
// Synchroniser les préférences quand initialPreferences change
|
||||
useEffect(() => {
|
||||
setPreferences(initialPreferences);
|
||||
setFormData(computedFormData);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 result = await updateSitePreferences(apiData);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setPreferences(result.data);
|
||||
// Réinitialiser formData avec les nouvelles valeurs (ou images par défaut)
|
||||
setFormData({
|
||||
homeBackground: getFormValue(
|
||||
result.data.homeBackground,
|
||||
DEFAULT_IMAGES.home
|
||||
),
|
||||
eventsBackground: getFormValue(
|
||||
result.data.eventsBackground,
|
||||
DEFAULT_IMAGES.events
|
||||
),
|
||||
leaderboardBackground: getFormValue(
|
||||
result.data.leaderboardBackground,
|
||||
DEFAULT_IMAGES.leaderboard
|
||||
),
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
console.error("Error updating preferences:", result.error);
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating preferences:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card variant="default" className="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}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="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} variant="success" size="md">
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Button onClick={handleCancel} variant="secondary" size="md">
|
||||
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>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
422
components/admin/EventManagement.tsx
Normal file
422
components/admin/EventManagement.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { calculateEventStatus } from "@/lib/eventStatus";
|
||||
import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events";
|
||||
import { Input, Textarea, Button, Card, Badge } from "@/components/ui";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
date: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: "ATELIER" | "KATA" | "PRESENTATION" | "LEARNING_HOUR";
|
||||
status: "UPCOMING" | "LIVE" | "PAST";
|
||||
room?: string | null;
|
||||
time?: string | null;
|
||||
maxPlaces?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
registrationsCount?: number;
|
||||
}
|
||||
|
||||
interface EventFormData {
|
||||
date: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: "ATELIER" | "KATA" | "PRESENTATION" | "LEARNING_HOUR";
|
||||
room?: string;
|
||||
time?: string;
|
||||
maxPlaces?: number;
|
||||
}
|
||||
|
||||
const eventTypes: Event["type"][] = [
|
||||
"ATELIER",
|
||||
"KATA",
|
||||
"PRESENTATION",
|
||||
"LEARNING_HOUR",
|
||||
];
|
||||
const getEventTypeLabel = (type: Event["type"]) => {
|
||||
switch (type) {
|
||||
case "ATELIER":
|
||||
return "Atelier";
|
||||
case "KATA":
|
||||
return "Kata";
|
||||
case "PRESENTATION":
|
||||
return "Présentation";
|
||||
case "LEARNING_HOUR":
|
||||
return "Learning Hour";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: Event["status"]) => {
|
||||
switch (status) {
|
||||
case "UPCOMING":
|
||||
return "À venir";
|
||||
case "LIVE":
|
||||
return "En cours";
|
||||
case "PAST":
|
||||
return "Passé";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventManagement() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<EventFormData>({
|
||||
date: "",
|
||||
name: "",
|
||||
description: "",
|
||||
type: "ATELIER",
|
||||
room: "",
|
||||
time: "",
|
||||
maxPlaces: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/events");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEvents(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching events:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setIsCreating(true);
|
||||
setEditingEvent(null);
|
||||
setFormData({
|
||||
date: "",
|
||||
name: "",
|
||||
description: "",
|
||||
type: "ATELIER",
|
||||
room: "",
|
||||
time: "",
|
||||
maxPlaces: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
setEditingEvent(event);
|
||||
setIsCreating(false);
|
||||
setFormData({
|
||||
date: event.date,
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
type: event.type,
|
||||
room: event.room || "",
|
||||
time: event.time || "",
|
||||
maxPlaces: event.maxPlaces || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
let result;
|
||||
if (isCreating) {
|
||||
result = await createEvent(formData);
|
||||
} else if (editingEvent) {
|
||||
result = await updateEvent(editingEvent.id, formData);
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
await fetchEvents();
|
||||
setEditingEvent(null);
|
||||
setIsCreating(false);
|
||||
setFormData({
|
||||
date: "",
|
||||
name: "",
|
||||
description: "",
|
||||
type: "ATELIER",
|
||||
room: "",
|
||||
time: "",
|
||||
maxPlaces: undefined,
|
||||
});
|
||||
} else {
|
||||
alert(result?.error || "Erreur lors de la sauvegarde");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving event:", error);
|
||||
alert("Erreur lors de la sauvegarde");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (eventId: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer cet événement ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteEvent(eventId);
|
||||
|
||||
if (result.success) {
|
||||
await fetchEvents();
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting event:", error);
|
||||
alert("Erreur lors de la suppression");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingEvent(null);
|
||||
setIsCreating(false);
|
||||
setFormData({
|
||||
date: "",
|
||||
name: "",
|
||||
description: "",
|
||||
type: "ATELIER",
|
||||
room: "",
|
||||
time: "",
|
||||
maxPlaces: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-400 py-8">Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-4">
|
||||
<h3 className="text-lg sm:text-xl font-gaming font-bold text-pixel-gold break-words">
|
||||
Événements ({events.length})
|
||||
</h3>
|
||||
{!isCreating && !editingEvent && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
+ Nouvel événement
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isCreating || editingEvent) && (
|
||||
<Card variant="default" className="p-3 sm:p-4 mb-4">
|
||||
<h4 className="text-pixel-gold font-bold mb-4 text-base sm:text-lg break-words">
|
||||
{isCreating ? "Créer un événement" : "Modifier l'événement"}
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="date"
|
||||
label="Date"
|
||||
value={formData.date}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, date: e.target.value })
|
||||
}
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label="Nom"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="Nom de l'événement"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="Description de l'événement"
|
||||
rows={4}
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm text-gray-300 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
type: e.target.value as Event["type"],
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm"
|
||||
>
|
||||
{eventTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getEventTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
label="Salle"
|
||||
value={formData.room || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, room: e.target.value })
|
||||
}
|
||||
placeholder="Ex: Nautilus"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label="Heure"
|
||||
value={formData.time || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, time: e.target.value })
|
||||
}
|
||||
placeholder="Ex: 11h-12h"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Places max"
|
||||
value={formData.maxPlaces || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
maxPlaces: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Ex: 25"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} variant="secondary" size="md">
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Aucun événement trouvé
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => {
|
||||
const status = calculateEventStatus(event.date);
|
||||
const statusVariant =
|
||||
status === "UPCOMING"
|
||||
? "success"
|
||||
: status === "LIVE"
|
||||
? "warning"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<Card key={event.id} variant="default" className="p-3 sm:p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
|
||||
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
{event.name}
|
||||
</h4>
|
||||
<Badge variant="default" size="sm">
|
||||
{getEventTypeLabel(event.type)}
|
||||
</Badge>
|
||||
<Badge variant={statusVariant} size="sm">
|
||||
{getStatusLabel(status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs sm:text-sm mb-2 break-words">
|
||||
{event.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2">
|
||||
<p className="text-gray-500 text-[10px] sm:text-xs whitespace-nowrap">
|
||||
Date: {new Date(event.date).toLocaleDateString("fr-FR")}
|
||||
</p>
|
||||
{event.room && (
|
||||
<p className="text-gray-500 text-[10px] sm:text-xs whitespace-nowrap">
|
||||
📍 Salle: {event.room}
|
||||
</p>
|
||||
)}
|
||||
{event.time && (
|
||||
<p className="text-gray-500 text-[10px] sm:text-xs whitespace-nowrap">
|
||||
🕐 Heure: {event.time}
|
||||
</p>
|
||||
)}
|
||||
{event.maxPlaces && (
|
||||
<p className="text-gray-500 text-[10px] sm:text-xs whitespace-nowrap">
|
||||
👥 Places: {event.maxPlaces}
|
||||
</p>
|
||||
)}
|
||||
<Badge variant="info" size="sm">
|
||||
{event.registrationsCount || 0} inscrit
|
||||
{event.registrationsCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{!isCreating && !editingEvent && (
|
||||
<div className="flex gap-2 sm:ml-4 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => handleEdit(event)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(event.id)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
components/admin/FeedbackManagement.tsx
Normal file
233
components/admin/FeedbackManagement.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
event: {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
type: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EventStatistics {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
eventDate: string | null;
|
||||
eventType: string | null;
|
||||
averageRating: number;
|
||||
feedbackCount: number;
|
||||
}
|
||||
|
||||
export default function FeedbackManagement() {
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [statistics, setStatistics] = useState<EventStatistics[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedbacks();
|
||||
}, []);
|
||||
|
||||
const fetchFeedbacks = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/feedback");
|
||||
if (!response.ok) {
|
||||
setError("Erreur lors du chargement des feedbacks");
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setFeedbacks(data.feedbacks || []);
|
||||
setStatistics(data.statistics || []);
|
||||
} catch {
|
||||
setError("Erreur lors du chargement des feedbacks");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "ATELIER":
|
||||
return "Atelier";
|
||||
case "KATA":
|
||||
return "Kata";
|
||||
case "PRESENTATION":
|
||||
return "Présentation";
|
||||
case "LEARNING_HOUR":
|
||||
return "Learning Hour";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span
|
||||
key={star}
|
||||
className={`text-sm sm:text-lg ${
|
||||
star <= rating ? "text-pixel-gold" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
<span className="text-gray-400 text-xs sm:text-sm ml-1 sm:ml-2">
|
||||
({rating}/5)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredFeedbacks = selectedEvent
|
||||
? feedbacks.filter((f) => f.event.id === selectedEvent)
|
||||
: feedbacks;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-4 sm:p-8">
|
||||
<p className="text-gray-400 text-center text-sm">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Statistiques par événement */}
|
||||
{statistics.length > 0 && (
|
||||
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-3 sm:p-6">
|
||||
<h3 className="text-pixel-gold font-bold text-base sm:text-lg mb-4 break-words">
|
||||
Statistiques par événement
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{statistics.map((stat) => (
|
||||
<div
|
||||
key={stat.eventId}
|
||||
className={`bg-black/40 border rounded p-3 sm:p-4 cursor-pointer transition ${
|
||||
selectedEvent === stat.eventId
|
||||
? "border-pixel-gold bg-pixel-gold/10"
|
||||
: "border-pixel-gold/30 hover:border-pixel-gold/50"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setSelectedEvent(
|
||||
selectedEvent === stat.eventId ? null : stat.eventId
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 mb-2">
|
||||
<h4 className="text-white font-semibold text-xs sm:text-sm break-words">
|
||||
{stat.eventName}
|
||||
</h4>
|
||||
<span className="text-pixel-gold text-[10px] sm:text-xs uppercase whitespace-nowrap flex-shrink-0">
|
||||
{stat.eventType && getEventTypeLabel(stat.eventType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{renderStars(Math.round(stat.averageRating))}
|
||||
</div>
|
||||
<div className="text-gray-400 text-[10px] sm:text-xs">
|
||||
Moyenne: {stat.averageRating.toFixed(2)}/5
|
||||
</div>
|
||||
<div className="text-gray-400 text-[10px] sm:text-xs">
|
||||
{stat.feedbackCount} feedback
|
||||
{stat.feedbackCount > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedEvent && (
|
||||
<button
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="mt-4 text-pixel-gold text-xs sm:text-sm hover:text-orange-400 transition"
|
||||
>
|
||||
Voir tous les feedbacks
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des feedbacks */}
|
||||
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-3 sm:p-6">
|
||||
<h3 className="text-pixel-gold font-bold text-base sm:text-lg mb-4 break-words">
|
||||
{selectedEvent
|
||||
? `Feedbacks pour: ${
|
||||
statistics.find((s) => s.eventId === selectedEvent)?.eventName
|
||||
}`
|
||||
: "Tous les feedbacks"}
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-3 sm:px-4 py-2 sm:py-3 rounded text-xs sm:text-sm mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFeedbacks.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8 text-sm">
|
||||
Aucun feedback pour le moment
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{filteredFeedbacks.map((feedback) => (
|
||||
<div
|
||||
key={feedback.id}
|
||||
className="bg-black/40 border border-pixel-gold/20 rounded p-3 sm:p-4"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3 mb-2">
|
||||
<h4 className="text-white font-semibold text-sm sm:text-base break-words">
|
||||
{feedback.user.username}
|
||||
</h4>
|
||||
<span className="text-gray-500 text-[10px] sm:text-xs break-all">
|
||||
{feedback.user.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-pixel-gold text-xs sm:text-sm font-semibold mb-2 break-words">
|
||||
{feedback.event.name}
|
||||
</div>
|
||||
<div className="text-gray-500 text-[10px] sm:text-xs mb-2">
|
||||
{new Date(feedback.createdAt).toLocaleDateString(
|
||||
"fr-FR",
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{renderStars(feedback.rating)}
|
||||
</div>
|
||||
</div>
|
||||
{feedback.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-pixel-gold/20">
|
||||
<p className="text-gray-300 text-xs sm:text-sm whitespace-pre-wrap break-words">
|
||||
{feedback.comment}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
755
components/admin/UserManagement.tsx
Normal file
755
components/admin/UserManagement.tsx
Normal file
@@ -0,0 +1,755 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { Avatar, Input, Button, Card } from "@/components/ui";
|
||||
import { updateUser, deleteUser } from "@/actions/admin/users";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
score: number;
|
||||
level: number;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
xp: number;
|
||||
maxXp: number;
|
||||
avatar: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface EditingUser {
|
||||
userId: string;
|
||||
username: string | null;
|
||||
avatar: string | null;
|
||||
hpDelta: number;
|
||||
xpDelta: number;
|
||||
score: number | null;
|
||||
level: number | null;
|
||||
role: string | null;
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/users");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
hpDelta: 0,
|
||||
xpDelta: 0,
|
||||
score: user.score,
|
||||
level: user.level,
|
||||
role: user.role,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingUser) return;
|
||||
|
||||
setSaving(true);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const body: {
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
hpDelta?: number;
|
||||
xpDelta?: number;
|
||||
score?: number;
|
||||
level?: number;
|
||||
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;
|
||||
}
|
||||
if (editingUser.xpDelta !== 0) {
|
||||
body.xpDelta = editingUser.xpDelta;
|
||||
}
|
||||
if (editingUser.score !== null) {
|
||||
body.score = editingUser.score;
|
||||
}
|
||||
if (editingUser.level !== null) {
|
||||
body.level = editingUser.level;
|
||||
}
|
||||
if (editingUser.role !== null) {
|
||||
body.role = editingUser.role;
|
||||
}
|
||||
|
||||
const result = await updateUser(editingUser.userId, body);
|
||||
|
||||
if (result.success) {
|
||||
await fetchUsers();
|
||||
setEditingUser(null);
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingUserId(userId);
|
||||
try {
|
||||
const result = await deleteUser(userId);
|
||||
|
||||
if (result.success) {
|
||||
await fetchUsers();
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
alert("Erreur lors de la suppression");
|
||||
} finally {
|
||||
setDeletingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString("en-US");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-400 py-8">Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Aucun utilisateur trouvé
|
||||
</div>
|
||||
) : (
|
||||
users.map((user) => {
|
||||
const isEditing = editingUser?.userId === user.id;
|
||||
const previewHp = isEditing
|
||||
? Math.max(0, Math.min(user.maxHp, user.hp + editingUser.hpDelta))
|
||||
: user.hp;
|
||||
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 (
|
||||
<Card key={user.id} variant="default" className="p-3 sm:p-4">
|
||||
<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 */}
|
||||
<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">
|
||||
{displayUsername}
|
||||
</h3>
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
|
||||
Niveau {user.level}
|
||||
</span>
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 whitespace-nowrap">
|
||||
Score: {formatNumber(user.score)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] sm:text-xs whitespace-nowrap ${
|
||||
user.role === "ADMIN"
|
||||
? "text-pixel-gold"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-[10px] sm:text-xs truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex gap-2 flex-shrink-0 sm:ml-2">
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
className="px-2 sm:px-3 py-1.5 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"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={deletingUserId === user.id}
|
||||
className="px-2 sm:px-3 py-1.5 border border-red-500/50 bg-red-900/20 text-red-400 uppercase text-[10px] sm:text-xs tracking-widest rounded hover:bg-red-900/30 transition disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{deletingUserId === user.id
|
||||
? "Suppression..."
|
||||
: "Supprimer"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
{/* Username Section */}
|
||||
<Input
|
||||
type="text"
|
||||
label="Nom d'utilisateur"
|
||||
value={editingUser.username || ""}
|
||||
onChange={(e) =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Nom d'utilisateur"
|
||||
className="text-xs sm:text-sm px-2 sm:px-3 py-1"
|
||||
/>
|
||||
|
||||
{/* 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/avatars/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}`}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
as="span"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{uploadingAvatar === user.id
|
||||
? "Upload en cours..."
|
||||
: "Upload un avatar custom"}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HP Section */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-xs sm:text-sm text-gray-300">
|
||||
Points de Vie (HP)
|
||||
</label>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400">
|
||||
{previewHp} / {user.maxHp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 sm:gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
hpDelta: editingUser.hpDelta - 100,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
|
||||
>
|
||||
-100
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
hpDelta: editingUser.hpDelta - 10,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
|
||||
>
|
||||
-10
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={editingUser.hpDelta || 0}
|
||||
onChange={(e) =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
hpDelta: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
hpDelta: editingUser.hpDelta + 10,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
|
||||
>
|
||||
+10
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
hpDelta: editingUser.hpDelta + 100,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
|
||||
>
|
||||
+100
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-black/60 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-red-600 to-green-500 transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(previewHp / user.maxHp) * 100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XP Section */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-xs sm:text-sm text-gray-300">
|
||||
Expérience (XP)
|
||||
</label>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400">
|
||||
{formatNumber(previewXp)} / {formatNumber(user.maxXp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 sm:gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
xpDelta: editingUser.xpDelta - 1000,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
|
||||
>
|
||||
-1000
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
xpDelta: editingUser.xpDelta - 100,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
|
||||
>
|
||||
-100
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={editingUser.xpDelta || 0}
|
||||
onChange={(e) =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
xpDelta: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
xpDelta: editingUser.xpDelta + 100,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
|
||||
>
|
||||
+100
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
xpDelta: editingUser.xpDelta + 1000,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
|
||||
>
|
||||
+1000
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-black/60 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-600 to-purple-500 transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(previewXp / user.maxXp) * 100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score Section */}
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
|
||||
Score
|
||||
</label>
|
||||
<div className="flex gap-1 sm:gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
score: (editingUser.score || 0) - 1000,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
|
||||
>
|
||||
-1000
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
score: (editingUser.score || 0) - 100,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
|
||||
>
|
||||
-100
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={editingUser.score ?? 0}
|
||||
onChange={(e) =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
score: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
score: (editingUser.score || 0) + 100,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
|
||||
>
|
||||
+100
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
score: (editingUser.score || 0) + 1000,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
|
||||
>
|
||||
+1000
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level Section */}
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
|
||||
Niveau
|
||||
</label>
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
level: Math.max(1, (editingUser.level || 1) - 1),
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0"
|
||||
>
|
||||
-1
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={editingUser.level ?? 1}
|
||||
onChange={(e) =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
level: Math.max(1, parseInt(e.target.value) || 1),
|
||||
})
|
||||
}
|
||||
className="flex-1 min-w-[60px] px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
level: (editingUser.level || 1) + 1,
|
||||
})
|
||||
}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0"
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Section */}
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm text-gray-300 mb-2">
|
||||
Rôle
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
role: "USER",
|
||||
})
|
||||
}
|
||||
className={`flex-1 px-3 sm:px-4 py-2 border rounded text-[10px] sm:text-xs uppercase tracking-widest transition ${
|
||||
editingUser.role === "USER"
|
||||
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold"
|
||||
: "border-gray-600/50 bg-gray-900/20 text-gray-400 hover:bg-gray-900/30"
|
||||
}`}
|
||||
>
|
||||
USER
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
...editingUser,
|
||||
role: "ADMIN",
|
||||
})
|
||||
}
|
||||
className={`flex-1 px-3 sm:px-4 py-2 border rounded text-[10px] sm:text-xs uppercase tracking-widest transition ${
|
||||
editingUser.role === "ADMIN"
|
||||
? "border-pixel-gold bg-pixel-gold/20 text-pixel-gold"
|
||||
: "border-gray-600/50 bg-gray-900/20 text-gray-400 hover:bg-gray-900/30"
|
||||
}`}
|
||||
>
|
||||
ADMIN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 text-[10px] sm:text-xs">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-0.5">
|
||||
<span className="text-gray-400">HP</span>
|
||||
<span className="text-gray-400">
|
||||
{user.hp}/{user.maxHp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-black/60 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-red-600 to-green-500"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(user.hp / user.maxHp) * 100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-0.5">
|
||||
<span className="text-gray-400">XP</span>
|
||||
<span className="text-gray-400">
|
||||
{formatNumber(user.xp)}/{formatNumber(user.maxXp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-black/60 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-600 to-purple-500"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(user.xp / user.maxXp) * 100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user