bf67c58171
Create the Board Score PWA with offline support, Kurzgesagt-style themes, multiple game configs, dynamic players, avatars via DiceBear, and complete statistics.
277 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|