Initial commit
Create the Board Score PWA with offline support, Kurzgesagt-style themes, multiple game configs, dynamic players, avatars via DiceBear, and complete statistics.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user