Add house leaderboard feature: Integrate house leaderboard functionality in LeaderboardPage and LeaderboardSection components. Update userStatsService to fetch house leaderboard data, and enhance UI to display house rankings, scores, and member details. Update Prisma schema to include house-related models and relationships, and seed database with initial house data.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled

This commit is contained in:
Julien Froidefond
2025-12-17 13:35:18 +01:00
parent cb02b494f4
commit 85ee812ab1
36 changed files with 5422 additions and 13 deletions

View File

@@ -0,0 +1,327 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { useSession } from "next-auth/react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import SectionTitle from "@/components/ui/SectionTitle";
import HouseForm from "./HouseForm";
import RequestList from "./RequestList";
import Alert from "@/components/ui/Alert";
import { deleteHouse, leaveHouse } from "@/actions/houses/update";
import { inviteUser } from "@/actions/houses/invitations";
interface House {
id: string;
name: string;
description: string | null;
creator: {
id: string;
username: string;
avatar: string | null;
};
memberships?: Array<{
id: string;
role: string;
user: {
id: string;
username: string;
avatar: string | null;
score?: number;
level?: number;
};
}>;
}
interface User {
id: string;
username: string;
avatar: string | null;
}
interface HouseManagementProps {
house: House | null;
users?: User[];
requests?: Array<{
id: string;
requester: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}>;
onUpdate?: () => void;
}
interface Request {
id: string;
requester: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}
export default function HouseManagement({
house,
users = [],
requests: initialRequests = [],
onUpdate,
}: HouseManagementProps) {
const { data: session } = useSession();
const [isEditing, setIsEditing] = useState(false);
const [showInviteForm, setShowInviteForm] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [requests, setRequests] = useState<Request[]>(initialRequests);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const userRole = house?.memberships?.find(
(m) => m.user.id === session?.user?.id
)?.role;
const isOwner = userRole === "OWNER";
const isAdmin = userRole === "ADMIN" || isOwner;
const pendingRequests = requests.filter((r) => r.status === "PENDING");
useEffect(() => {
const fetchRequests = async () => {
if (!house || !isAdmin) return;
try {
const response = await fetch(`/api/houses/${house.id}/requests?status=PENDING`);
if (response.ok) {
const data = await response.json();
setRequests(data);
}
} catch (error) {
console.error("Error fetching requests:", error);
}
};
fetchRequests();
}, [house?.id, isAdmin]);
const handleDelete = () => {
if (!house || !confirm("Êtes-vous sûr de vouloir supprimer cette maison ?")) {
return;
}
setError(null);
startTransition(async () => {
const result = await deleteHouse(house.id);
if (result.success) {
onUpdate?.();
} else {
setError(result.error || "Erreur lors de la suppression");
}
});
};
const handleLeave = () => {
if (!house || !confirm("Êtes-vous sûr de vouloir quitter cette maison ?")) {
return;
}
setError(null);
startTransition(async () => {
const result = await leaveHouse(house.id);
if (result.success) {
onUpdate?.();
} else {
setError(result.error || "Erreur lors de la sortie");
}
});
};
const handleInvite = () => {
if (!house || !selectedUserId) return;
setError(null);
setSuccess(null);
startTransition(async () => {
const result = await inviteUser(house.id, selectedUserId);
if (result.success) {
setSuccess("Invitation envoyée");
setShowInviteForm(false);
setSelectedUserId("");
onUpdate?.();
} else {
setError(result.error || "Erreur lors de l'envoi de l'invitation");
}
});
};
const availableUsers = users.filter(
(u) =>
u.id !== session?.user?.id &&
!house?.memberships?.some((m) => m.user.id === u.id)
);
if (!house) {
return (
<Card className="p-6">
<SectionTitle>Ma Maison</SectionTitle>
<p className="text-sm mb-4" style={{ color: "var(--muted-foreground)" }}>
Vous n'êtes membre d'aucune maison pour le moment.
</p>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
<div className="flex-1 min-w-0">
<SectionTitle>{house.name}</SectionTitle>
{house.description && (
<p className="text-sm mt-2 break-words" style={{ color: "var(--muted-foreground)" }}>
{house.description}
</p>
)}
</div>
<div className="flex flex-wrap gap-2 sm:flex-nowrap">
{isAdmin && (
<>
<Button
onClick={() => setIsEditing(!isEditing)}
variant="secondary"
size="sm"
className="flex-1 sm:flex-none"
>
{isEditing ? "Annuler" : "Modifier"}
</Button>
{isOwner && (
<Button onClick={handleDelete} variant="danger" size="sm" className="flex-1 sm:flex-none">
Supprimer
</Button>
)}
</>
)}
{!isOwner && (
<Button onClick={handleLeave} variant="danger" size="sm" className="flex-1 sm:flex-none">
Quitter
</Button>
)}
</div>
</div>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
{isEditing ? (
<HouseForm
house={house}
onSuccess={() => {
setIsEditing(false);
onUpdate?.();
}}
onCancel={() => setIsEditing(false)}
/>
) : (
<div>
<h3 className="font-bold mb-3" style={{ color: "var(--foreground)" }}>
Membres ({house.memberships?.length ?? 0})
</h3>
<div className="space-y-2">
{(house.memberships || []).map((membership) => (
<div
key={membership.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-2 rounded"
style={{ backgroundColor: "var(--card-hover)" }}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{membership.user.avatar && (
<img
src={membership.user.avatar}
alt={membership.user.username}
className="w-8 h-8 rounded-full flex-shrink-0"
/>
)}
<div className="min-w-0">
<span className="font-semibold block sm:inline" style={{ color: "var(--foreground)" }}>
{membership.user.username}
</span>
<span className="text-xs block sm:inline sm:ml-2" style={{ color: "var(--muted-foreground)" }}>
({membership.user.score} pts - Niveau {membership.user.level})
</span>
</div>
</div>
<span className="text-xs uppercase flex-shrink-0" style={{ color: "var(--accent)" }}>
{membership.role}
</span>
</div>
))}
</div>
{isAdmin && (
<div className="mt-4">
{showInviteForm ? (
<div className="space-y-2">
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className="w-full p-2 rounded border"
style={{
backgroundColor: "var(--input)",
borderColor: "var(--border)",
color: "var(--foreground)",
}}
>
<option value="">Sélectionner un utilisateur</option>
{availableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
<div className="flex gap-2">
<Button
onClick={handleInvite}
disabled={!selectedUserId || isPending}
variant="primary"
size="sm"
>
{isPending ? "Envoi..." : "Inviter"}
</Button>
<Button
onClick={() => {
setShowInviteForm(false);
setSelectedUserId("");
}}
variant="secondary"
size="sm"
>
Annuler
</Button>
</div>
</div>
) : (
<Button
onClick={() => setShowInviteForm(true)}
variant="primary"
size="sm"
>
Inviter un utilisateur
</Button>
)}
</div>
)}
</div>
)}
</Card>
{isAdmin && pendingRequests.length > 0 && (
<Card className="p-4 sm:p-6">
<SectionTitle>Demandes d'adhésion</SectionTitle>
<RequestList requests={pendingRequests} onUpdate={onUpdate} />
</Card>
)}
</div>
);
}