536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useTransition } from "react";
|
|
import {
|
|
validateChallenge,
|
|
rejectChallenge,
|
|
updateChallenge,
|
|
deleteChallenge,
|
|
} from "@/actions/admin/challenges";
|
|
import { Button, Card, Input, Textarea, Alert } from "@/components/ui";
|
|
import { Avatar } from "@/components/ui";
|
|
|
|
interface Challenge {
|
|
id: string;
|
|
challenger: {
|
|
id: string;
|
|
username: string;
|
|
avatar: string | null;
|
|
};
|
|
challenged: {
|
|
id: string;
|
|
username: string;
|
|
avatar: string | null;
|
|
};
|
|
title: string;
|
|
description: string;
|
|
pointsReward: number;
|
|
status: string;
|
|
adminComment: string | null;
|
|
createdAt: string;
|
|
acceptedAt: string | null;
|
|
}
|
|
|
|
export default function ChallengeManagement() {
|
|
const [challenges, setChallenges] = useState<Challenge[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>(
|
|
null
|
|
);
|
|
const [editingChallenge, setEditingChallenge] = useState<Challenge | null>(
|
|
null
|
|
);
|
|
const [winnerId, setWinnerId] = useState<string>("");
|
|
const [adminComment, setAdminComment] = useState("");
|
|
const [editTitle, setEditTitle] = useState("");
|
|
const [editDescription, setEditDescription] = useState("");
|
|
const [editPointsReward, setEditPointsReward] = useState<number>(0);
|
|
const [isPending, startTransition] = useTransition();
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchChallenges();
|
|
}, []);
|
|
|
|
const fetchChallenges = async () => {
|
|
try {
|
|
const response = await fetch("/api/admin/challenges");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setChallenges(data);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching challenges:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleValidate = async () => {
|
|
if (!selectedChallenge || !winnerId) {
|
|
setErrorMessage("Veuillez sélectionner un gagnant");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = await validateChallenge(
|
|
selectedChallenge.id,
|
|
winnerId,
|
|
adminComment || undefined
|
|
);
|
|
|
|
if (result.success) {
|
|
setSuccessMessage(
|
|
"Défi validé avec succès ! Les points ont été attribués."
|
|
);
|
|
setSelectedChallenge(null);
|
|
setWinnerId("");
|
|
setAdminComment("");
|
|
fetchChallenges();
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de la validation");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleReject = async () => {
|
|
if (!selectedChallenge) return;
|
|
|
|
if (!confirm("Êtes-vous sûr de vouloir rejeter ce défi ?")) {
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = await rejectChallenge(
|
|
selectedChallenge.id,
|
|
adminComment || undefined
|
|
);
|
|
|
|
if (result.success) {
|
|
setSuccessMessage("Défi rejeté");
|
|
setSelectedChallenge(null);
|
|
setAdminComment("");
|
|
fetchChallenges();
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors du rejet");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleEdit = (challenge: Challenge) => {
|
|
setEditingChallenge(challenge);
|
|
setEditTitle(challenge.title);
|
|
setEditDescription(challenge.description);
|
|
setEditPointsReward(challenge.pointsReward);
|
|
};
|
|
|
|
const handleUpdate = async () => {
|
|
if (!editingChallenge) return;
|
|
|
|
startTransition(async () => {
|
|
const result = await updateChallenge(editingChallenge.id, {
|
|
title: editTitle,
|
|
description: editDescription,
|
|
pointsReward: editPointsReward,
|
|
});
|
|
|
|
if (result.success) {
|
|
setSuccessMessage("Défi mis à jour avec succès");
|
|
setEditingChallenge(null);
|
|
setEditTitle("");
|
|
setEditDescription("");
|
|
setEditPointsReward(0);
|
|
fetchChallenges();
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de la mise à jour");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleDelete = async (challengeId: string) => {
|
|
if (
|
|
!confirm(
|
|
"Êtes-vous sûr de vouloir supprimer ce défi ? Cette action est irréversible."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = await deleteChallenge(challengeId);
|
|
|
|
if (result.success) {
|
|
setSuccessMessage("Défi supprimé avec succès");
|
|
fetchChallenges();
|
|
setTimeout(() => setSuccessMessage(null), 5000);
|
|
} else {
|
|
setErrorMessage(result.error || "Erreur lors de la suppression");
|
|
setTimeout(() => setErrorMessage(null), 5000);
|
|
}
|
|
});
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="text-center text-pixel-gold py-8">Chargement...</div>
|
|
);
|
|
}
|
|
|
|
if (challenges.length === 0) {
|
|
return (
|
|
<div className="text-center text-gray-400 py-8">
|
|
Aucun défi en attente
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED");
|
|
const pendingChallenges = challenges.filter((c) => c.status === "PENDING");
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{successMessage && (
|
|
<Alert variant="success" className="mb-4">
|
|
{successMessage}
|
|
</Alert>
|
|
)}
|
|
{errorMessage && (
|
|
<Alert variant="error" className="mb-4">
|
|
{errorMessage}
|
|
</Alert>
|
|
)}
|
|
<div className="text-sm text-gray-400 mb-4">
|
|
{acceptedChallenges.length} défi
|
|
{acceptedChallenges.length > 1 ? "s" : ""} en attente de validation
|
|
{pendingChallenges.length > 0 && (
|
|
<span className="ml-2">
|
|
• {pendingChallenges.length} défi
|
|
{pendingChallenges.length > 1 ? "s" : ""} en attente d'acceptation
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{challenges.map((challenge) => (
|
|
<Card key={challenge.id} variant="dark" className="p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-bold text-pixel-gold mb-2">
|
|
{challenge.title}
|
|
</h3>
|
|
<p className="text-gray-300 mb-4">{challenge.description}</p>
|
|
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar
|
|
src={challenge.challenger.avatar}
|
|
username={challenge.challenger.username}
|
|
size="sm"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
{challenge.challenger.username}
|
|
</span>
|
|
<span className="text-xs text-gray-500">VS</span>
|
|
<Avatar
|
|
src={challenge.challenged.avatar}
|
|
username={challenge.challenged.username}
|
|
size="sm"
|
|
/>
|
|
<span className="text-sm text-gray-300">
|
|
{challenge.challenged.username}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-400">
|
|
Récompense:{" "}
|
|
<span className="text-pixel-gold font-bold">
|
|
{challenge.pointsReward} points
|
|
</span>
|
|
</div>
|
|
<div className="text-xs mt-2">
|
|
<span
|
|
className={`px-2 py-1 rounded ${
|
|
challenge.status === "ACCEPTED"
|
|
? "bg-green-500/20 text-green-400"
|
|
: "bg-yellow-500/20 text-yellow-400"
|
|
}`}
|
|
>
|
|
{challenge.status === "ACCEPTED"
|
|
? "Accepté"
|
|
: "En attente d'acceptation"}
|
|
</span>
|
|
</div>
|
|
{challenge.acceptedAt && (
|
|
<div className="text-xs text-gray-500 mt-2">
|
|
Accepté le:{" "}
|
|
{new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Button
|
|
onClick={() => handleEdit(challenge)}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
Modifier
|
|
</Button>
|
|
{challenge.status === "ACCEPTED" && (
|
|
<Button
|
|
onClick={() => setSelectedChallenge(challenge)}
|
|
variant="primary"
|
|
size="sm"
|
|
>
|
|
Valider/Rejeter
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={() => handleDelete(challenge.id)}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
style={{
|
|
color: "var(--destructive)",
|
|
borderColor: "var(--destructive)",
|
|
}}
|
|
>
|
|
Supprimer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
|
|
{/* Modal de validation */}
|
|
{selectedChallenge && (
|
|
<div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
|
onClick={() => {
|
|
setSelectedChallenge(null);
|
|
setWinnerId("");
|
|
setAdminComment("");
|
|
}}
|
|
>
|
|
<Card
|
|
variant="dark"
|
|
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6">
|
|
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
|
|
Valider/Rejeter le défi
|
|
</h2>
|
|
|
|
<div className="mb-6">
|
|
<h3 className="text-lg font-bold text-gray-300 mb-2">
|
|
{selectedChallenge.title}
|
|
</h3>
|
|
<p className="text-gray-400 mb-4">
|
|
{selectedChallenge.description}
|
|
</p>
|
|
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar
|
|
src={selectedChallenge.challenger.avatar}
|
|
username={selectedChallenge.challenger.username}
|
|
size="md"
|
|
/>
|
|
<span className="text-gray-300">
|
|
{selectedChallenge.challenger.username}
|
|
</span>
|
|
</div>
|
|
<span className="text-gray-500">VS</span>
|
|
<div className="flex items-center gap-2">
|
|
<Avatar
|
|
src={selectedChallenge.challenged.avatar}
|
|
username={selectedChallenge.challenged.username}
|
|
size="md"
|
|
/>
|
|
<span className="text-gray-300">
|
|
{selectedChallenge.challenged.username}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
|
Sélectionner le gagnant
|
|
</label>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="winner"
|
|
value={selectedChallenge.challenger.id}
|
|
checked={winnerId === selectedChallenge.challenger.id}
|
|
onChange={(e) => setWinnerId(e.target.value)}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span className="text-gray-300">
|
|
{selectedChallenge.challenger.username}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="winner"
|
|
value={selectedChallenge.challenged.id}
|
|
checked={winnerId === selectedChallenge.challenged.id}
|
|
onChange={(e) => setWinnerId(e.target.value)}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span className="text-gray-300">
|
|
{selectedChallenge.challenged.username}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
|
Commentaire (optionnel)
|
|
</label>
|
|
<textarea
|
|
value={adminComment}
|
|
onChange={(e) => setAdminComment(e.target.value)}
|
|
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
|
|
rows={3}
|
|
placeholder="Commentaire pour les joueurs..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<Button
|
|
onClick={handleValidate}
|
|
variant="primary"
|
|
disabled={!winnerId || isPending}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? "Validation..." : "Valider le défi"}
|
|
</Button>
|
|
<Button
|
|
onClick={handleReject}
|
|
variant="secondary"
|
|
disabled={isPending}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? "Rejet..." : "Rejeter le défi"}
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setSelectedChallenge(null);
|
|
setWinnerId("");
|
|
setAdminComment("");
|
|
}}
|
|
variant="secondary"
|
|
disabled={isPending}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal d'édition */}
|
|
{editingChallenge && (
|
|
<div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
|
onClick={() => {
|
|
setEditingChallenge(null);
|
|
setEditTitle("");
|
|
setEditDescription("");
|
|
setEditPointsReward(0);
|
|
}}
|
|
>
|
|
<Card
|
|
variant="dark"
|
|
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6">
|
|
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
|
|
Modifier le défi
|
|
</h2>
|
|
|
|
<div className="space-y-4">
|
|
<Input
|
|
id="edit-title"
|
|
label="Titre"
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
required
|
|
placeholder="Titre du défi"
|
|
/>
|
|
|
|
<Textarea
|
|
id="edit-description"
|
|
label="Description"
|
|
value={editDescription}
|
|
onChange={(e) => setEditDescription(e.target.value)}
|
|
required
|
|
rows={4}
|
|
placeholder="Description du défi"
|
|
/>
|
|
|
|
<Input
|
|
id="edit-points"
|
|
label="Récompense (points)"
|
|
type="number"
|
|
min="1"
|
|
value={editPointsReward}
|
|
onChange={(e) =>
|
|
setEditPointsReward(parseInt(e.target.value) || 0)
|
|
}
|
|
required
|
|
placeholder="100"
|
|
/>
|
|
|
|
<div className="flex gap-4 pt-4">
|
|
<Button
|
|
onClick={handleUpdate}
|
|
variant="primary"
|
|
disabled={
|
|
isPending ||
|
|
!editTitle ||
|
|
!editDescription ||
|
|
editPointsReward <= 0
|
|
}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? "Mise à jour..." : "Enregistrer"}
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setEditingChallenge(null);
|
|
setEditTitle("");
|
|
setEditDescription("");
|
|
setEditPointsReward(0);
|
|
}}
|
|
variant="secondary"
|
|
disabled={isPending}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|