feat(web): DiceBear lorelei avatars for players

- Avatar component using DiceBear 9.x lorelei, seeded by the player name
  (stable, recognizable across screens)
- shown in the lobby list, the in-game player cards (gold ring on leader),
  and the final leaderboard (large on the winner, small on runners-up)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-09 10:50:17 +02:00
parent 2fce63530b
commit 4f10d86fb0
4 changed files with 31 additions and 3 deletions

View file

@ -0,0 +1,12 @@
/** Avatar DiceBear (style lorelei), seedé par le pseudo → stable et reconnaissable. */
export function Avatar({ seed, className }: { seed: string; className?: string }) {
const url = `https://api.dicebear.com/9.x/lorelei/svg?seed=${encodeURIComponent(seed)}`
return (
<img
src={url}
alt={`Avatar de ${seed}`}
loading="lazy"
className={`bg-muted shrink-0 rounded-full ${className ?? ""}`}
/>
)
}

View file

@ -4,6 +4,7 @@ import { Crown } from "lucide-react"
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared" import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
@ -53,12 +54,17 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
initial={{ y: -28, rotate: -25, opacity: 0 }} initial={{ y: -28, rotate: -25, opacity: 0 }}
animate={{ y: 0, rotate: 0, opacity: 1 }} animate={{ y: 0, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14, delay: 0.2 }} transition={{ type: "spring", stiffness: 500, damping: 14, delay: 0.2 }}
className="absolute -top-5" className="absolute -top-5 z-10"
> >
<Crown className="size-9 fill-amber-400 text-amber-400 drop-shadow" /> <Crown className="size-9 fill-amber-400 text-amber-400 drop-shadow" />
</motion.span> </motion.span>
<span className="font-heading mt-2 text-xl font-bold text-balance"> <Avatar
seed={nameOf(winner.playerId)}
className="size-20 ring-2 ring-amber-400"
/>
<span className="font-heading mt-1 text-xl font-bold text-balance">
{nameOf(winner.playerId)} {nameOf(winner.playerId)}
{isMe(winner.playerId) && ( {isMe(winner.playerId) && (
<span className="text-muted-foreground text-sm"> (toi)</span> <span className="text-muted-foreground text-sm"> (toi)</span>
@ -86,6 +92,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
<span className="text-muted-foreground w-5 text-right tabular-nums"> <span className="text-muted-foreground w-5 text-right tabular-nums">
{i + 2} {i + 2}
</span> </span>
<Avatar seed={nameOf(s.playerId)} className="size-7" />
<span> <span>
{nameOf(s.playerId)} {nameOf(s.playerId)}
{isMe(s.playerId) && ( {isMe(s.playerId) && (

View file

@ -2,6 +2,7 @@ import { useState } from "react"
import type { RoomSnapshot } from "@nerdware/shared" import type { RoomSnapshot } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
const QUESTION_OPTIONS = [3, 5, 10] const QUESTION_OPTIONS = [3, 5, 10]
@ -38,7 +39,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
key={p.id} key={p.id}
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm" className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
> >
<span className={p.connected ? "" : "opacity-40"}> <span
className={`flex items-center gap-2 ${p.connected ? "" : "opacity-40"}`}
>
<Avatar seed={p.name} className="size-7" />
{p.name} {p.name}
{p.id === playerId && ( {p.id === playerId && (
<span className="text-muted-foreground"> (toi)</span> <span className="text-muted-foreground"> (toi)</span>

View file

@ -1,6 +1,7 @@
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { Check, Crown } from "lucide-react" import { Check, Crown } from "lucide-react"
import type { RoomSnapshot } from "@nerdware/shared" import type { RoomSnapshot } from "@nerdware/shared"
import { Avatar } from "@/components/avatar"
interface PlayerCardsProps { interface PlayerCardsProps {
snapshot: RoomSnapshot snapshot: RoomSnapshot
@ -58,6 +59,10 @@ export function PlayerCards({
<Crown className="size-4 fill-amber-400" /> <Crown className="size-4 fill-amber-400" />
</motion.span> </motion.span>
)} )}
<Avatar
seed={p.name}
className={`size-9 ${isLeader ? "ring-2 ring-amber-400" : ""}`}
/>
<span className="max-w-24 truncate text-xs font-medium"> <span className="max-w-24 truncate text-xs font-medium">
{p.name} {p.name}
{isMe && <span className="text-muted-foreground"> (toi)</span>} {isMe && <span className="text-muted-foreground"> (toi)</span>}