nerdware/apps/web/src/pages/room.tsx
AyoubBenziza 1e5ef2be90 feat(web): i18n (FR/EN) — homemade typed dictionary
- lightweight, dependency-free i18n: typed fr/en dictionaries (fr is the source
  of truth, en must match its shape → compile error on drift), I18nProvider +
  useI18n hook, browser detection + localStorage persistence
- FR/EN language switcher (home + room header)
- translated the whole player-facing flow: home, join, pseudo, room shell,
  lobby (incl. settings, categories, bots, track submission, history),
  quiz, blindtest, player-cards, round transitions, end screen (podium, awards,
  tracks, rounds recap), and server-error messages (mapped by code)
- history dates localized to the active language

Content (questions/categories) intentionally left as-is. Back-office (admin)
not translated yet — internal, token-gated tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:03:28 +02:00

158 lines
5.5 KiB
TypeScript

import { Link, useLocation } from "wouter"
import { AnimatePresence, motion } from "framer-motion"
import { LogOut } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { useRoomStore } from "@/store/room"
import { LobbyView } from "@/components/lobby-view"
import { QuizView } from "@/components/quiz-view"
import { BlindtestView } from "@/components/blindtest-view"
import { GameEndView } from "@/components/game-end-view"
import { PlayerCards } from "@/components/player-cards"
import { RoomCode } from "@/components/room-code"
import { Boom } from "@/components/boom"
import { RoundTransition } from "@/components/round-transition"
import { PseudoScreen } from "@/components/pseudo-screen"
import { useI18n } from "@/i18n/context"
import { LanguageToggle } from "@/i18n/language-toggle"
export function RoomPage({ code }: { code: string }) {
const [, navigate] = useLocation()
const { t } = useI18n()
const snapshot = useRoomStore((s) => s.snapshot)
const roomCode = useRoomStore((s) => s.roomCode)
const leaveRoom = useRoomStore((s) => s.leaveRoom)
const playerId = useRoomStore((s) => s.playerId)
const connected = useRoomStore((s) => s.connected)
const pseudoSet = useRoomStore((s) => s.pseudoSet)
const boomKey = useRoomStore((s) => s.boomKey)
const roundKey = useRoomStore((s) => s.roundKey)
const voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round)
const roundKind = useRoomStore((s) => s.roundKind)
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil.
if (roomCode !== code) {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground text-sm">{t.room.notFound}</p>
<Link href="/">
<Button variant="secondary">{t.common.back}</Button>
</Link>
</div>
)
}
// Pseudo pas encore choisi : on l'affiche d'abord.
if (!pseudoSet) {
return <PseudoScreen code={code} />
}
// En attente du premier snapshot.
if (!snapshot || snapshot.code !== code) {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">{t.common.loading}</p>
</div>
)
}
// Phase grossière : on n'anime qu'aux frontières lobby ↔ jeu ↔ fin pour
// préserver le lecteur YouTube entre in_round / reveal / scores.
const phase =
snapshot.status === "lobby"
? "lobby"
: snapshot.status === "ended"
? "ended"
: "game"
return (
<div className="relative flex min-h-svh items-start justify-center overflow-hidden p-4 pt-10 sm:p-6 sm:pt-12">
{/* Halo d'ambiance en fond */}
<div
aria-hidden
className="pointer-events-none absolute top-[-20vmin] -z-10 size-[90vmin] rounded-full bg-gradient-to-br from-fuchsia-500/10 via-purple-500/5 to-cyan-400/10 blur-3xl"
/>
<div
className={`flex w-full flex-col gap-6 transition-[max-width] ${
phase === "lobby"
? "max-w-3xl"
: phase === "ended"
? "max-w-4xl"
: "max-w-lg"
}`}
>
<header className="flex items-center justify-between">
<RoomCode code={snapshot.code} />
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<span
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
/>
</TooltipTrigger>
<TooltipContent>
{connected ? t.room.connected : t.room.disconnected}
</TooltipContent>
</Tooltip>
<LanguageToggle />
<Button
size="sm"
variant="secondary"
onClick={() => {
leaveRoom()
navigate("/")
}}
>
<LogOut /> {t.room.leave}
</Button>
</div>
</header>
<AnimatePresence mode="wait">
<motion.div
key={phase}
className="flex flex-col gap-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.22, ease: "easeOut" }}
>
{phase === "lobby" && <LobbyView snapshot={snapshot} />}
{phase === "game" && (
<>
<PlayerCards
snapshot={snapshot}
playerId={playerId}
showVoteStatus={snapshot.status === "in_round"}
votedIds={voteProgress?.voted}
/>
{round?.type === "blindtest" ? (
<BlindtestView snapshot={snapshot} />
) : (
<QuizView snapshot={snapshot} />
)}
</>
)}
{phase === "ended" && <GameEndView snapshot={snapshot} />}
</motion.div>
</AnimatePresence>
</div>
{boomKey > 0 && <Boom key={boomKey} />}
{roundKey > 0 && round && (
<RoundTransition
key={roundKey}
kind={roundKind}
index={snapshot.currentRound}
modeChanged={roundModeChanged}
/>
)}
</div>
)
}