Files
Skori/src/pages/Players.tsx
T
Zed bf67c58171 Initial commit
Create the Board Score PWA with offline support, Kurzgesagt-style themes, multiple game configs, dynamic players, avatars via DiceBear, and complete statistics.
2026-07-06 16:12:25 +02:00

277 lines
10 KiB
TypeScript

import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import {
Users,
Trash2,
Edit2,
Check,
X,
Camera,
Dices,
Loader2,
} from "lucide-react";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { Avatar } from "../components/ui/avatar";
import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
export default function Players() {
const players = useLiveQuery(() => db.players.orderBy("name").toArray());
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) {
await db.players.delete(id);
}
};
const startEdit = (id: string, name: string) => {
setEditingId(id);
setEditName(name);
};
const saveEdit = async (id: string) => {
if (editName.trim()) {
const newName = editName.trim();
const playerToEdit = players?.find((p) => p.id === id);
await db.players.update(id, { name: newName });
// Update the player's name in all existing game sessions (history)
const sessions = await db.sessions.toArray();
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
const newPlayers = session.players.map((p) => {
// Check by ID or fallback to exact name matching if older data
if (p.id === id || (playerToEdit && p.name === playerToEdit.name)) {
hasChanges = true;
return { ...p, name: newName };
}
return p;
});
if (hasChanges) {
return { ...session, players: newPlayers };
}
return null;
})
.filter((s) => s !== null) as typeof sessions;
if (updatedSessions.length > 0) {
await db.sessions.bulkPut(updatedSessions);
}
}
setEditingId(null);
};
const updatePlayerAvatarGlobally = async (
playerId: string,
avatarData: string,
) => {
await db.players.update(playerId, { avatar: avatarData });
// Update historical sessions too if we want the avatar to reflect immediately everywhere
const sessions = await db.sessions.toArray();
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
const newPlayers = session.players.map((p) => {
if (p.id === playerId) {
hasChanges = true;
return { ...p, avatar: avatarData };
}
return p;
});
if (hasChanges) return { ...session, players: newPlayers };
return null;
})
.filter((s) => s !== null) as typeof sessions;
if (updatedSessions.length > 0) {
await db.sessions.bulkPut(updatedSessions);
}
};
const handleAvatarChange = async (
playerId: string,
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const base64Image = await resizeImage(file);
await updatePlayerAvatarGlobally(playerId, base64Image);
} catch (e) {
alert("Erreur lors de l'enregistrement de l'image.");
}
};
const handleGenerateRandomAvatar = async (playerId: string) => {
setIsGenerating(playerId);
try {
// Seed aléatoire
const seed = Math.random().toString(36).substring(7);
// Utilisation du style "bottts-neutral"
const url = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${seed}&backgroundColor=transparent`;
const response = await fetch(url);
const svgText = await response.text();
// On encode l'SVG proprement pour l'utiliser comme image dans l'app
const encodedSvg = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
await updatePlayerAvatarGlobally(playerId, encodedSvg);
} catch (e) {
alert(
"Impossible de générer l'avatar. Vérifiez votre connexion internet.",
);
} finally {
setIsGenerating(null);
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Joueurs
</h1>
</header>
<div className="space-y-4">
{!players || players.length === 0 ? (
<div className="text-center text-muted-foreground py-12 flex flex-col items-center bg-background/60 backdrop-blur-sm rounded-[2rem]">
<Users className="w-16 h-16 mb-4 opacity-20" />
<p className="font-bold text-lg">Aucun joueur enregistré.</p>
<p className="text-sm mt-1 max-w-[250px]">
Ils s'ajouteront automatiquement lors de votre prochaine partie.
</p>
</div>
) : (
players.map((player, index) => (
<motion.div
key={player.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 100,
}}
>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md overflow-visible relative group">
{/* Decorative blob */}
<div className="absolute -left-2 -top-2 w-8 h-8 rounded-full bg-primary/20 blur-md pointer-events-none group-hover:bg-primary/40 transition-colors" />
<CardContent className="p-4 flex items-center justify-between">
{editingId === player.id ? (
<div className="flex items-center space-x-2 flex-1 mr-2 relative z-10">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12"
autoFocus
/>
<Button
variant="ghost"
size="icon"
onClick={() => saveEdit(player.id)}
className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
>
<Check className="w-6 h-6" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingId(null)}
className="text-muted-foreground bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<X className="w-6 h-6" />
</Button>
</div>
) : (
<>
<div className="flex flex-col items-center mr-4 shrink-0 gap-1.5">
<div
className="relative group/avatar cursor-pointer"
onClick={() => {
const input = document.getElementById(
`avatar-upload-${player.id}`,
);
input?.click();
}}
>
<Avatar
src={player.avatar}
name={player.name}
size="lg"
className="shadow-md border-4 border-background"
/>
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
id={`avatar-upload-${player.id}`}
className="hidden"
accept="image/*"
onChange={(e) => handleAvatarChange(player.id, e)}
/>
</div>
<Button
variant="ghost"
size="sm"
disabled={isGenerating === player.id}
className="h-6 px-3 rounded-full text-[10px] font-black uppercase tracking-wider bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
onClick={() => handleGenerateRandomAvatar(player.id)}
>
{isGenerating === player.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Dices className="w-3 h-3 mr-1" />
)}
Aléatoire
</Button>
</div>
<span className="font-black text-xl truncate flex-1 tracking-tight">
{player.name}
</span>
<div className="flex items-center shrink-0 space-x-1 relative z-10">
<Button
variant="ghost"
size="icon"
onClick={() => startEdit(player.id, player.name)}
className="text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<Edit2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(player.id, player.name)}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</>
)}
</CardContent>
</Card>
</motion.div>
))
)}
</div>
</div>
);
}