Compare commits

..

No commits in common. "5d113e1d37cf126a55f7aa8d36097cb455e87317" and "5dcd7ac978211c7657c1feb711d1506ded26ceac" have entirely different histories.

18 changed files with 223 additions and 737 deletions

View file

@ -14,7 +14,6 @@ import { useRoomStore } from "@/store/room"
import { useYoutube, type YoutubeApi } from "@/lib/youtube" import { useYoutube, type YoutubeApi } from "@/lib/youtube"
import { Countdown } from "@/components/countdown" import { Countdown } from "@/components/countdown"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
import { useI18n } from "@/i18n/context"
const inputClass = const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
@ -34,7 +33,6 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const voteBlindtest = useRoomStore((s) => s.voteBlindtest) const voteBlindtest = useRoomStore((s) => s.voteBlindtest)
const mediaControl = useRoomStore((s) => s.mediaControl) const mediaControl = useRoomStore((s) => s.mediaControl)
const { t } = useI18n()
const track = round?.payload as BlindtestRoundPayload | undefined const track = round?.payload as BlindtestRoundPayload | undefined
const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "") const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "")
@ -70,7 +68,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="flex w-full max-w-lg flex-col gap-5"> <div className="flex w-full max-w-lg flex-col gap-5">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase"> <span className="text-muted-foreground text-xs uppercase">
{t.game.blindtest(snapshot.currentRound + 1, snapshot.totalRounds)} Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds}
</span> </span>
{!showReveal && round && ( {!showReveal && round && (
<Countdown <Countdown
@ -138,7 +136,6 @@ function DjControls({
api: YoutubeApi api: YoutubeApi
mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void
}) { }) {
const { t } = useI18n()
const [pos, setPos] = useState(0) const [pos, setPos] = useState(0)
const [dur, setDur] = useState(0) const [dur, setDur] = useState(0)
@ -156,7 +153,7 @@ function DjControls({
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="text-muted-foreground text-center text-xs uppercase"> <span className="text-muted-foreground text-center text-xs uppercase">
{t.blindtest.djHint} Tu es le DJ 🎧 pilote la lecture (et vote comme les autres)
</span> </span>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
<Button <Button
@ -167,7 +164,7 @@ function DjControls({
mediaControl("play", api.time()) mediaControl("play", api.time())
}} }}
> >
<Play /> {t.blindtest.play} <Play /> Play
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -178,7 +175,7 @@ function DjControls({
mediaControl("pause", api.time()) mediaControl("pause", api.time())
}} }}
> >
<Pause /> {t.blindtest.pause} <Pause /> Pause
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -190,7 +187,7 @@ function DjControls({
mediaControl("seek", 0) mediaControl("seek", 0)
}} }}
> >
<Rewind /> {t.blindtest.restart} <Rewind /> Début
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2 text-xs tabular-nums"> <div className="flex items-center gap-2 text-xs tabular-nums">
@ -217,15 +214,14 @@ function DjControls({
} }
function MisleadCard({ mode }: { mode: BlindtestMode }) { function MisleadCard({ mode }: { mode: BlindtestMode }) {
const { t } = useI18n()
return ( return (
<div className="border-primary/40 bg-primary/5 flex flex-col items-center gap-2 rounded-xl border p-4 text-center"> <div className="border-primary/40 bg-primary/5 flex flex-col items-center gap-2 rounded-xl border p-4 text-center">
<span className="text-2xl">🤫</span> <span className="text-2xl">🤫</span>
<p className="font-heading font-bold">{t.blindtest.yourTrack}</p> <p className="font-heading font-bold">C'est ton titre !</p>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{mode === "title_artist" {mode === "title_artist"
? t.blindtest.yourTrackTitleArtist ? "Tu ne votes pas. Savoure pendant que les autres cherchent."
: t.blindtest.yourTrackWhoAdded} : "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé."}
</p> </p>
</div> </div>
) )
@ -244,7 +240,6 @@ function VoteForm({
disabled: boolean disabled: boolean
onVote: (answer: BlindtestAnswer) => void onVote: (answer: BlindtestAnswer) => void
}) { }) {
const { t } = useI18n()
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [artist, setArtist] = useState("") const [artist, setArtist] = useState("")
const [guessed, setGuessed] = useState<string | null>(null) const [guessed, setGuessed] = useState<string | null>(null)
@ -275,7 +270,7 @@ function VoteForm({
if (disabled) { if (disabled) {
return ( return (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
{t.game.answerSent} Réponse envoyée en attente des autres
</p> </p>
) )
} }
@ -286,13 +281,13 @@ function VoteForm({
<> <>
<input <input
className={inputClass} className={inputClass}
placeholder={t.blindtest.titlePlaceholder} placeholder="Titre"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
<input <input
className={inputClass} className={inputClass}
placeholder={t.blindtest.artistPlaceholder} placeholder="Artiste"
value={artist} value={artist}
onChange={(e) => setArtist(e.target.value)} onChange={(e) => setArtist(e.target.value)}
/> />
@ -319,7 +314,7 @@ function VoteForm({
</div> </div>
)} )}
<Button disabled={!canSubmit} onClick={submit}> <Button disabled={!canSubmit} onClick={submit}>
{t.blindtest.submit} Valider ma réponse
</Button> </Button>
</div> </div>
) )
@ -332,13 +327,12 @@ function RevealCard({
truth: BlindtestRevealTruth truth: BlindtestRevealTruth
result?: BlindtestPerPlayerResult[string] result?: BlindtestPerPlayerResult[string]
}) { }) {
const { t } = useI18n()
return ( return (
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<p className="font-heading text-lg font-bold text-balance">{truth.title}</p> <p className="font-heading text-lg font-bold text-balance">{truth.title}</p>
<p className="text-muted-foreground text-sm">{truth.artist}</p> <p className="text-muted-foreground text-sm">{truth.artist}</p>
<p className="flex items-center gap-2 text-sm"> <p className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t.blindtest.revealAddedBy}</span> <span className="text-muted-foreground">Ajouté par</span>
<Avatar seed={truth.submittedByName} className="size-6" /> <Avatar seed={truth.submittedByName} className="size-6" />
<span className="font-medium">{truth.submittedByName}</span> <span className="font-medium">{truth.submittedByName}</span>
</p> </p>
@ -346,7 +340,7 @@ function RevealCard({
<p <p
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`} className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
> >
{result.points > 0 ? t.blindtest.points(result.points) : t.game.wrong} {result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"}
</p> </p>
)} )}
</div> </div>

View file

@ -10,11 +10,6 @@ import type {
RoundRecap, RoundRecap,
} from "@nerdware/shared" } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
const recapImage = (url: string) => const recapImage = (url: string) =>
@ -23,7 +18,6 @@ import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
import { celebrate } from "@/lib/confetti" import { celebrate } from "@/lib/confetti"
import { playVictory } from "@/lib/sound" import { playVictory } from "@/lib/sound"
import { useI18n, type Dict } from "@/i18n/context"
/** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */ /** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */
function spotifySearch(track: BlindtestTrackInfo): string { function spotifySearch(track: BlindtestTrackInfo): string {
@ -141,7 +135,6 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const gameRecap = useRoomStore((s) => s.gameRecap) const gameRecap = useRoomStore((s) => s.gameRecap)
const leaveRoom = useRoomStore((s) => s.leaveRoom) const leaveRoom = useRoomStore((s) => s.leaveRoom)
const returnToLobby = useRoomStore((s) => s.returnToLobby) const returnToLobby = useRoomStore((s) => s.returnToLobby)
const { t } = useI18n()
const isHost = snapshot.hostId === playerId const isHost = snapshot.hostId === playerId
// Confettis de victoire (une fois à l'arrivée sur l'écran de fin). // Confettis de victoire (une fois à l'arrivée sur l'écran de fin).
@ -156,12 +149,12 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
async function copyResults() { async function copyResults() {
const lines = [t.end.resultsTitle, "", t.end.ranking] const lines = ["NerdWare — Résultats", "", "🏆 Classement"]
ranked.forEach((s, i) => ranked.forEach((s, i) =>
lines.push(`${i + 1}. ${nameOf(s.playerId)}${s.score}`) lines.push(`${i + 1}. ${nameOf(s.playerId)}${s.score}`)
) )
if (gameRecap && gameRecap.length > 0) { if (gameRecap && gameRecap.length > 0) {
lines.push("", t.end.rounds) lines.push("", "Manches")
gameRecap.forEach((r) => gameRecap.forEach((r) =>
lines.push( lines.push(
`${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}` `${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}`
@ -192,9 +185,11 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="flex w-full flex-col items-center gap-6"> <div className="flex w-full flex-col items-center gap-6">
<div className="flex w-full max-w-xl flex-col gap-6 text-center"> <div className="flex w-full max-w-xl flex-col gap-6 text-center">
<header> <header>
<p className="text-muted-foreground text-xs uppercase">{t.end.over}</p> <p className="text-muted-foreground text-xs uppercase">
Partie terminée
</p>
<h2 className="font-heading text-2xl font-bold"> <h2 className="font-heading text-2xl font-bold">
{t.end.winner(first ? nameOf(first.playerId) : "?")} 🏆 {first ? nameOf(first.playerId) : "?"} l'emporte !
</h2> </h2>
</header> </header>
@ -259,7 +254,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
)} )}
{gameRecap && gameRecap.length > 0 && ( {gameRecap && gameRecap.length > 0 && (
<FunStats recap={gameRecap} snapshot={snapshot} t={t} /> <FunStats recap={gameRecap} snapshot={snapshot} />
)} )}
</div> </div>
@ -273,14 +268,9 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
: "flex w-full max-w-xl flex-col gap-6" : "flex w-full max-w-xl flex-col gap-6"
} }
> >
{hasTracks && <TracksRecap tracks={gameTracks!} t={t} />} {hasTracks && <TracksRecap tracks={gameTracks!} />}
{hasRecap && ( {hasRecap && (
<RoundsRecap <RoundsRecap recap={gameRecap!} nameOf={nameOf} playerId={playerId} />
recap={gameRecap!}
nameOf={nameOf}
playerId={playerId}
t={t}
/>
)} )}
</div> </div>
)} )}
@ -288,20 +278,20 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="flex w-full max-w-sm flex-col gap-2"> <div className="flex w-full max-w-sm flex-col gap-2">
<Button variant="secondary" className="w-full" onClick={copyResults}> <Button variant="secondary" className="w-full" onClick={copyResults}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />} {copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? t.end.copied : t.end.copy} {copied ? "Copié !" : "Copier les résultats"}
</Button> </Button>
{isHost ? ( {isHost ? (
<Button className="w-full" onClick={returnToLobby}> <Button className="w-full" onClick={returnToLobby}>
{t.end.replay} Rejouer
</Button> </Button>
) : ( ) : (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{t.end.waitHostReplay} En attente que l'hôte relance une partie
</p> </p>
)} )}
<Link href="/"> <Link href="/">
<Button variant="secondary" className="w-full" onClick={leaveRoom}> <Button variant="secondary" className="w-full" onClick={leaveRoom}>
{t.end.leave} Quitter
</Button> </Button>
</Link> </Link>
</div> </div>
@ -309,7 +299,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
) )
} }
function TracksRecap({ tracks, t }: { tracks: BlindtestTrackInfo[]; t: Dict }) { function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
async function copyPlaylist() { async function copyPlaylist() {
@ -327,52 +317,45 @@ function TracksRecap({ tracks, t }: { tracks: BlindtestTrackInfo[]; t: Dict }) {
<section className="flex flex-col gap-2 text-left"> <section className="flex flex-col gap-2 text-left">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium"> <h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> {t.end.tracksTitle} <Music className="size-4" /> Les musiques de la partie
</h3> </h3>
<Button size="sm" variant="secondary" onClick={copyPlaylist}> <Button size="sm" variant="secondary" onClick={copyPlaylist}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />} {copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? t.roomCode.copied : t.end.playlist} {copied ? "Copié" : "Playlist"}
</Button> </Button>
</div> </div>
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{tracks.map((track) => ( {tracks.map((t) => (
<li <li
key={track.youtubeId} key={t.youtubeId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2" className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
> >
<img <img
src={`https://img.youtube.com/vi/${track.youtubeId}/mqdefault.jpg`} src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
alt="" alt=""
className="h-10 w-16 shrink-0 rounded object-cover" className="h-10 w-16 shrink-0 rounded object-cover"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="line-clamp-1 text-xs font-medium"> <span className="line-clamp-1 text-xs font-medium">{t.title}</span>
{track.title}
</span>
<span className="text-muted-foreground line-clamp-1 text-[10px]"> <span className="text-muted-foreground line-clamp-1 text-[10px]">
{t.end.addedBy(track.submittedByName)} ajouté par {t.submittedByName}
</span> </span>
</span> </span>
<Tooltip> <a
<TooltipTrigger asChild> href={spotifySearch(t)}
<a href={spotifySearch(track)} target="_blank" rel="noreferrer"> target="_blank"
<Button size="icon-sm" variant="secondary"> rel="noreferrer"
<SiSpotify color="default" /> title="Chercher sur Spotify"
</Button> >
</a> <Button size="icon-sm" variant="secondary">
</TooltipTrigger> <SiSpotify color="default" />
<TooltipContent>{t.end.searchSpotify}</TooltipContent> </Button>
</Tooltip> </a>
<Tooltip> <a href={t.url} target="_blank" rel="noreferrer" title="Ouvrir sur YouTube">
<TooltipTrigger asChild> <Button size="icon-sm" variant="secondary">
<a href={track.url} target="_blank" rel="noreferrer"> <SiYoutube color="default" />
<Button size="icon-sm" variant="secondary"> </Button>
<SiYoutube color="default" /> </a>
</Button>
</a>
</TooltipTrigger>
<TooltipContent>{t.end.openYoutube}</TooltipContent>
</Tooltip>
</li> </li>
))} ))}
</ul> </ul>
@ -393,11 +376,9 @@ const QUIZ_BASE_POINTS = 100
function FunStats({ function FunStats({
recap, recap,
snapshot, snapshot,
t,
}: { }: {
recap: RoundRecap[] recap: RoundRecap[]
snapshot: RoomSnapshot snapshot: RoomSnapshot
t: Dict
}) { }) {
const stats = new Map<string, PlayerStat>( const stats = new Map<string, PlayerStat>(
snapshot.players.map((p) => [ snapshot.players.map((p) => [
@ -446,17 +427,17 @@ function FunStats({
(p) => total > 0 && stat(p.id).correct === total (p) => total > 0 && stat(p.id).correct === total
) )
if (perfect.length) { if (perfect.length) {
awards.push({ emoji: "🎯", label: t.end.awardPerfect, ids: perfect.map((p) => p.id) }) awards.push({ emoji: "🎯", label: "Sans-faute", ids: perfect.map((p) => p.id) })
} }
const brains = winners((s) => s.correct) const brains = winners((s) => s.correct)
if (brains.length && brains.length < players.length) { if (brains.length && brains.length < players.length) {
awards.push({ emoji: "🧠", label: t.end.awardBrain, ids: brains.map((p) => p.id) }) awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) })
} }
const fastest = winners((s) => s.speed) const fastest = winners((s) => s.speed)
if (fastest.length && fastest.length < players.length) { if (fastest.length && fastest.length < players.length) {
awards.push({ awards.push({
emoji: "⚡", emoji: "⚡",
label: t.end.awardFastest, label: "Le plus rapide",
ids: fastest.map((p) => p.id), ids: fastest.map((p) => p.id),
}) })
} }
@ -464,7 +445,7 @@ function FunStats({
if (decoy.length) { if (decoy.length) {
awards.push({ awards.push({
emoji: "🦹", emoji: "🦹",
label: t.end.awardDecoy, label: "Roi de la tromperie",
ids: decoy.map((p) => p.id), ids: decoy.map((p) => p.id),
}) })
} }
@ -472,7 +453,7 @@ function FunStats({
(p) => stat(p.id).answered > 0 && stat(p.id).correct === 0 (p) => stat(p.id).answered > 0 && stat(p.id).correct === 0
) )
if (boulets.length && total >= 3) { if (boulets.length && total >= 3) {
awards.push({ emoji: "🪨", label: t.end.awardBoulet, ids: boulets.map((p) => p.id) }) awards.push({ emoji: "🪨", label: "Le boulet", ids: boulets.map((p) => p.id) })
} }
if (awards.length === 0) { if (awards.length === 0) {
@ -481,7 +462,7 @@ function FunStats({
return ( return (
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
<h3 className="text-muted-foreground text-sm font-medium">{t.end.awards}</h3> <h3 className="text-muted-foreground text-sm font-medium">Récompenses</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{awards.map((a) => ( {awards.map((a) => (
<div <div
@ -509,17 +490,15 @@ function RoundsRecap({
recap, recap,
nameOf, nameOf,
playerId, playerId,
t,
}: { }: {
recap: RoundRecap[] recap: RoundRecap[]
nameOf: (id: string) => string nameOf: (id: string) => string
playerId: string | null playerId: string | null
t: Dict
}) { }) {
return ( return (
<details className="rounded-xl border text-left"> <details className="rounded-xl border text-left">
<summary className="text-muted-foreground flex cursor-pointer items-center gap-1.5 p-3 text-sm font-medium select-none"> <summary className="text-muted-foreground flex cursor-pointer items-center gap-1.5 p-3 text-sm font-medium select-none">
<ListChecks className="size-4" /> {t.end.recapTitle(recap.length)} <ListChecks className="size-4" /> Récapitulatif des manches ({recap.length})
</summary> </summary>
<ul className="flex flex-col gap-2 p-3 pt-0"> <ul className="flex flex-col gap-2 p-3 pt-0">
{recap.map((r) => { {recap.map((r) => {
@ -549,10 +528,7 @@ function RoundsRecap({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-muted-foreground text-[10px] uppercase"> <p className="text-muted-foreground text-[10px] uppercase">
{r.index + 1} ·{" "} {r.index + 1} ·{" "}
{r.category ?? {r.category ?? (r.type === "blindtest" ? "Blindtest" : "Quiz")}
(r.type === "blindtest"
? t.gameTypes.blindtest
: t.gameTypes.quiz)}
{" · "} {" · "}
<span className="text-green-500"> {found}</span> <span className="text-green-500"> {found}</span>
</p> </p>
@ -571,7 +547,7 @@ function RoundsRecap({
<ul className="mt-1 flex flex-col gap-0.5"> <ul className="mt-1 flex flex-col gap-0.5">
{answers.length === 0 ? ( {answers.length === 0 ? (
<li className="text-muted-foreground text-[10px]"> <li className="text-muted-foreground text-[10px]">
{t.end.noAnswer} Aucune réponse
</li> </li>
) : ( ) : (
answers.map((a) => ( answers.map((a) => (
@ -588,7 +564,7 @@ function RoundsRecap({
} }
> >
{nameOf(a.playerId)} {nameOf(a.playerId)}
{a.playerId === playerId && t.end.you} : {a.playerId === playerId && " (toi)"} :
</span> </span>
<span <span
className={ className={

View file

@ -18,11 +18,18 @@ import { SiYoutube } from "@icons-pack/react-simple-icons"
import { fetchCategories } from "@/lib/categories" import { fetchCategories } from "@/lib/categories"
import { fetchHistory } from "@/lib/history" import { fetchHistory } from "@/lib/history"
import type { import type {
BlindtestMode,
GameType, GameType,
MixMode, MixMode,
RoomSnapshot, RoomSnapshot,
RoundConfig, RoundConfig,
} from "@nerdware/shared" } from "@nerdware/shared"
const MIX_LABELS: Record<MixMode, string> = {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
}
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { import {
Tooltip, Tooltip,
@ -31,21 +38,32 @@ import {
} from "@workspace/ui/components/tooltip" } from "@workspace/ui/components/tooltip"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error" const BLINDTEST_LOCK_HINT = "3 joueurs dont 2 réels (un bot ne peut pas être DJ)"
const MAX_TRACKS = 10 const MAX_TRACKS = 10
const GAME_TYPES: { const GAME_TYPES: {
value: GameType value: GameType
label: string
Icon: LucideIcon Icon: LucideIcon
needsThree: boolean needsThree: boolean
}[] = [ }[] = [
{ value: "mixed", Icon: Shuffle, needsThree: false }, { value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: false },
{ value: "quiz", Icon: Brain, needsThree: false }, { value: "quiz", label: "Quiz", Icon: Brain, needsThree: false },
{ value: "image", Icon: ImageIcon, needsThree: false }, { value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
{ value: "blindtest", Icon: Headphones, needsThree: true }, { value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true },
] ]
const MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?",
mixed: "Mixte",
}
const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const
const BOT_DIFF_LABELS: Record<(typeof BOT_DIFFICULTIES)[number], string> = {
easy: "Facile",
normal: "Normal",
hard: "Difficile",
}
const inputClass = const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
@ -133,7 +151,6 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const startGame = useRoomStore((s) => s.startGame) const startGame = useRoomStore((s) => s.startGame)
const addBot = useRoomStore((s) => s.addBot) const addBot = useRoomStore((s) => s.addBot)
const removeBot = useRoomStore((s) => s.removeBot) const removeBot = useRoomStore((s) => s.removeBot)
const { t, lang } = useI18n()
const isHost = snapshot.hostId === playerId const isHost = snapshot.hostId === playerId
const botCount = snapshot.players.filter((p) => p.bot).length const botCount = snapshot.players.filter((p) => p.bot).length
const { const {
@ -224,7 +241,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
try { try {
await startGame(rounds) await startGame(rounds)
} catch (err) { } catch (err) {
setError(errorMessage(t, err)) setError((err as { message?: string }).message ?? "Erreur")
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -234,7 +251,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start"> <div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium"> <h2 className="text-muted-foreground text-sm font-medium">
{t.lobby.players(snapshot.players.length)} Joueurs ({snapshot.players.length})
</h2> </h2>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{snapshot.players.map((p) => ( {snapshot.players.map((p) => (
@ -248,12 +265,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
<Avatar seed={p.name} className="size-11" /> <Avatar seed={p.name} className="size-11" />
{p.name} {p.name}
{p.id === playerId && ( {p.id === playerId && (
<span className="text-muted-foreground"> {t.common.you}</span> <span className="text-muted-foreground"> (toi)</span>
)} )}
</span> </span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{p.bot ? t.lobby.bot : p.id === snapshot.hostId ? t.lobby.host : ""} {p.bot ? "🤖 Bot" : p.id === snapshot.hostId ? "Hôte" : ""}
{!p.bot && !p.connected && ` · ${t.lobby.offline}`} {!p.bot && !p.connected && " · hors ligne"}
</span> </span>
</li> </li>
))} ))}
@ -261,7 +278,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost && ( {isHost && (
<div className="flex items-center justify-between gap-2 pt-1"> <div className="flex items-center justify-between gap-2 pt-1">
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{t.lobby.bots} : {botCount} Bots : {botCount}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -289,14 +306,14 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost && ( {isHost && (
<section className="flex flex-col gap-4 rounded-xl border p-4"> <section className="flex flex-col gap-4 rounded-xl border p-4">
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide"> <h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{t.lobby.settings} Réglages de la partie
</h2> </h2>
{/* Mode de jeu */} {/* Mode de jeu */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium">{t.lobby.gameMode}</span> <span className="text-sm font-medium">Mode de jeu</span>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{GAME_TYPES.map(({ value, Icon, needsThree }) => { {GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
const locked = needsThree && !blindtestAvailable const locked = needsThree && !blindtestAvailable
const active = effectiveType === value const active = effectiveType === value
const tile = ( const tile = (
@ -314,13 +331,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
}`} }`}
> >
<Icon className="size-5" /> <Icon className="size-5" />
{t.gameTypes[value]} {label}
</button> </button>
) )
return locked ? ( return locked ? (
<Tooltip key={value}> <Tooltip key={value}>
<TooltipTrigger asChild>{tile}</TooltipTrigger> <TooltipTrigger asChild>{tile}</TooltipTrigger>
<TooltipContent>{t.lobby.blindtestLockHint}</TooltipContent> <TooltipContent>{BLINDTEST_LOCK_HINT}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
tile tile
@ -329,7 +346,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</div> </div>
{!blindtestAvailable && ( {!blindtestAvailable && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{t.lobby.blindtestUnlock} Le blindtest se débloque à 3 joueurs dont 2 réels (un bot ne peut
pas être DJ).
</p> </p>
)} )}
</div> </div>
@ -337,7 +355,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{/* Sous-modes du mixte (toggles) */} {/* Sous-modes du mixte (toggles) */}
{isMixed && ( {isMixed && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium">{t.lobby.subModes}</span> <span className="text-sm font-medium">Sous-modes inclus</span>
<div className="flex gap-2"> <div className="flex gap-2">
{(["quiz", "image", "blindtest"] as const).map((m) => { {(["quiz", "image", "blindtest"] as const).map((m) => {
const locked = m === "blindtest" && !blindtestAvailable const locked = m === "blindtest" && !blindtestAvailable
@ -356,13 +374,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
: "border-input hover:bg-muted/60" : "border-input hover:bg-muted/60"
}`} }`}
> >
{t.mixModes[m]} {MIX_LABELS[m]}
</button> </button>
) )
return locked ? ( return locked ? (
<Tooltip key={m}> <Tooltip key={m}>
<TooltipTrigger asChild>{tile}</TooltipTrigger> <TooltipTrigger asChild>{tile}</TooltipTrigger>
<TooltipContent>{t.lobby.blindtestLockHint}</TooltipContent> <TooltipContent>{BLINDTEST_LOCK_HINT}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
tile tile
@ -376,7 +394,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{showQuiz && ( {showQuiz && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium"> <span className="flex items-center gap-1.5 text-sm font-medium">
<Brain className="size-4" /> {t.lobby.quizCount} <Brain className="size-4" /> Questions de quiz
</span> </span>
<Stepper value={quizCount} min={1} max={20} onChange={setQuizCount} /> <Stepper value={quizCount} min={1} max={20} onChange={setQuizCount} />
</div> </div>
@ -387,7 +405,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium"> <span className="flex items-center gap-1.5 text-sm font-medium">
<ImageIcon className="size-4" /> {t.lobby.imageCount} <ImageIcon className="size-4" /> Images à deviner
</span> </span>
<Stepper <Stepper
value={imageCount} value={imageCount}
@ -396,7 +414,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
onChange={setImageCount} onChange={setImageCount}
/> />
</div> </div>
<p className="text-muted-foreground text-xs">{t.lobby.imageHint}</p> <p className="text-muted-foreground text-xs">
Nécessite des questions « Image » créées dans le back-office.
</p>
</div> </div>
)} )}
@ -404,7 +424,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{(showQuiz || showImage) && allCategories.length > 0 && ( {(showQuiz || showImage) && allCategories.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium"> <span className="flex items-center gap-1.5 text-sm font-medium">
<Filter className="size-4" /> {t.lobby.categories} <Filter className="size-4" /> Catégories
</span> </span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<button <button
@ -415,7 +435,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
: "border-input hover:bg-muted/60" : "border-input hover:bg-muted/60"
}`} }`}
> >
{t.lobby.allCategories} Toutes
</button> </button>
{allCategories.map((c) => ( {allCategories.map((c) => (
<button <button
@ -437,7 +457,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{/* Niveau des bots (si au moins un bot dans la partie) */} {/* Niveau des bots (si au moins un bot dans la partie) */}
{botCount > 0 && ( {botCount > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{t.lobby.botLevel}</span> <span className="text-sm font-medium">🤖 Niveau des bots</span>
<div className="flex gap-1"> <div className="flex gap-1">
{BOT_DIFFICULTIES.map((d) => ( {BOT_DIFFICULTIES.map((d) => (
<Button <Button
@ -447,7 +467,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
variant={botDifficulty === d ? "default" : "secondary"} variant={botDifficulty === d ? "default" : "secondary"}
onClick={() => updateSettings({ botDifficulty: d })} onClick={() => updateSettings({ botDifficulty: d })}
> >
{t.botDifficulty[d]} {BOT_DIFF_LABELS[d]}
</Button> </Button>
))} ))}
</div> </div>
@ -459,7 +479,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="flex flex-col gap-3 border-t pt-3"> <div className="flex flex-col gap-3 border-t pt-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium"> <span className="flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> {t.lobby.blindtestMode} <Music className="size-4" /> Mode blindtest
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => ( {(["title_artist", "who_added", "mixed"] as const).map((m) => (
@ -470,14 +490,14 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
variant={blindtestMode === m ? "default" : "secondary"} variant={blindtestMode === m ? "default" : "secondary"}
onClick={() => updateSettings({ blindtestMode: m })} onClick={() => updateSettings({ blindtestMode: m })}
> >
{t.blindtestModes[m]} {MODE_LABELS[m]}
</Button> </Button>
))} ))}
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium"> <span className="flex items-center gap-1.5 text-sm font-medium">
<ListMusic className="size-4" /> {t.lobby.tracksPerPlayer} <ListMusic className="size-4" /> Titres par joueur
</span> </span>
<Stepper <Stepper
value={tracksPerPlayer} value={tracksPerPlayer}
@ -496,19 +516,19 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost ? ( {isHost ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Button disabled={busy || !canStart} onClick={start}> <Button disabled={busy || !canStart} onClick={start}>
<Play />{" "} <Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
{busy ? t.lobby.launching : t.lobby.launch(rounds.length)}
</Button> </Button>
{showBlindtest && !allSubmitted && ( {showBlindtest && !allSubmitted && (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
{t.lobby.waitTracks(tracksPerPlayer)} En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "}
titre(s).
</p> </p>
)} )}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
{t.lobby.waitHost} En attente du lancement par l'hôte
{showBlindtest && t.lobby.tracksSubmitted(totalTracks)} {showBlindtest && ` (${totalTracks} titres soumis)`}
</p> </p>
)} )}
@ -517,7 +537,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{history.length > 0 && ( {history.length > 0 && (
<details className="rounded-xl border text-left"> <details className="rounded-xl border text-left">
<summary className="text-muted-foreground cursor-pointer p-3 text-sm font-medium select-none"> <summary className="text-muted-foreground cursor-pointer p-3 text-sm font-medium select-none">
{t.lobby.prevGames(history.length)} Parties précédentes ({history.length})
</summary> </summary>
<ul className="flex flex-col gap-2 p-3 pt-0"> <ul className="flex flex-col gap-2 p-3 pt-0">
{history.map((g) => { {history.map((g) => {
@ -525,10 +545,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
return ( return (
<li key={g.id} className="bg-muted/40 rounded-lg p-2 text-xs"> <li key={g.id} className="bg-muted/40 rounded-lg p-2 text-xs">
<p className="text-muted-foreground text-[10px] uppercase"> <p className="text-muted-foreground text-[10px] uppercase">
{new Date(g.playedAt).toLocaleString( {new Date(g.playedAt).toLocaleString("fr-FR", {
lang === "fr" ? "fr-FR" : "en-US", dateStyle: "short",
{ dateStyle: "short", timeStyle: "short" } timeStyle: "short",
)} })}
{" · "} {" · "}
{g.modes.join(", ")} {g.modes.join(", ")}
</p> </p>
@ -565,7 +585,6 @@ function TrackSubmission({
}) { }) {
const submitTrack = useRoomStore((s) => s.submitTrack) const submitTrack = useRoomStore((s) => s.submitTrack)
const removeTrack = useRoomStore((s) => s.removeTrack) const removeTrack = useRoomStore((s) => s.removeTrack)
const { t } = useI18n()
const [url, setUrl] = useState("") const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null) const [feedback, setFeedback] = useState<string | null>(null)
@ -586,7 +605,7 @@ function TrackSubmission({
]) ])
setUrl("") setUrl("")
} else { } else {
setFeedback(res.reason ?? t.lobby.rejected) setFeedback(res.reason ?? "Refusé")
} }
setSubmitting(false) setSubmitting(false)
} }
@ -601,48 +620,40 @@ function TrackSubmission({
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t.lobby.myTracks(myCount, tracksPerPlayer)} Tes titres ({myCount}/{tracksPerPlayer})
</span> </span>
{tracks.length > 0 && ( {tracks.length > 0 && (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{tracks.map((track) => ( {tracks.map((t) => (
<li <li
key={track.trackId} key={t.trackId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2" className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
> >
<img <img
src={`https://img.youtube.com/vi/${track.youtubeId}/mqdefault.jpg`} src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
alt="" alt=""
className="h-10 w-16 shrink-0 rounded object-cover" className="h-10 w-16 shrink-0 rounded object-cover"
/> />
<span className="line-clamp-2 flex-1 text-xs">{track.title}</span> <span className="line-clamp-2 flex-1 text-xs">{t.title}</span>
<Tooltip> <a
<TooltipTrigger asChild> href={`https://www.youtube.com/watch?v=${t.youtubeId}`}
<a target="_blank"
href={`https://www.youtube.com/watch?v=${track.youtubeId}`} rel="noreferrer"
target="_blank" title="Ouvrir sur YouTube"
rel="noreferrer" >
> <Button size="icon-sm" variant="secondary">
<Button size="icon-sm" variant="secondary"> <SiYoutube color="default" />
<SiYoutube color="default" /> </Button>
</Button> </a>
</a> <Button
</TooltipTrigger> size="icon-sm"
<TooltipContent>{t.lobby.openYoutube}</TooltipContent> variant="secondary"
</Tooltip> onClick={() => remove(t.trackId)}
<Tooltip> title="Supprimer"
<TooltipTrigger asChild> >
<Button <Trash2 />
size="icon-sm" </Button>
variant="secondary"
onClick={() => remove(track.trackId)}
>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent>{t.lobby.remove}</TooltipContent>
</Tooltip>
</li> </li>
))} ))}
</ul> </ul>
@ -652,7 +663,7 @@ function TrackSubmission({
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
className={inputClass} className={inputClass}
placeholder={t.lobby.youtubePlaceholder} placeholder="Lien YouTube"
value={url} value={url}
disabled={submitting} disabled={submitting}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
@ -663,7 +674,7 @@ function TrackSubmission({
disabled={submitting || !url.trim()} disabled={submitting || !url.trim()}
onClick={submit} onClick={submit}
> >
<SiYoutube color="default" /> {t.lobby.add} <SiYoutube color="default" /> Ajouter
</Button> </Button>
</div> </div>
)} )}

View file

@ -4,7 +4,6 @@ import { Check, Crown } from "lucide-react"
import type { RoomSnapshot } from "@nerdware/shared" import type { RoomSnapshot } from "@nerdware/shared"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
import { celebrateLead } from "@/lib/confetti" import { celebrateLead } from "@/lib/confetti"
import { useI18n } from "@/i18n/context"
interface PlayerCardsProps { interface PlayerCardsProps {
snapshot: RoomSnapshot snapshot: RoomSnapshot
@ -22,7 +21,6 @@ export function PlayerCards({
showVoteStatus = false, showVoteStatus = false,
votedIds = [], votedIds = [],
}: PlayerCardsProps) { }: PlayerCardsProps) {
const { t } = useI18n()
const scoreOf = (id: string) => const scoreOf = (id: string) =>
snapshot.scores.find((s) => s.playerId === id)?.score ?? 0 snapshot.scores.find((s) => s.playerId === id)?.score ?? 0
@ -81,9 +79,7 @@ export function PlayerCards({
/> />
<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 && ( {isMe && <span className="text-muted-foreground"> (toi)</span>}
<span className="text-muted-foreground"> {t.common.you}</span>
)}
</span> </span>
<motion.span <motion.span
@ -104,7 +100,7 @@ export function PlayerCards({
transition={{ type: "spring", stiffness: 600, damping: 18 }} transition={{ type: "spring", stiffness: 600, damping: 18 }}
className="flex items-center gap-0.5 text-[10px] font-medium text-green-500" className="flex items-center gap-0.5 text-[10px] font-medium text-green-500"
> >
<Check className="size-3" /> {t.playerCards.ready} <Check className="size-3" /> Prêt
</motion.span> </motion.span>
) : ( ) : (
<span className="flex items-center gap-1 text-[10px]"> <span className="flex items-center gap-1 text-[10px]">
@ -113,9 +109,7 @@ export function PlayerCards({
transition={{ duration: 1.1, repeat: Infinity }} transition={{ duration: 1.1, repeat: Infinity }}
className="bg-muted-foreground size-1.5 rounded-full" className="bg-muted-foreground size-1.5 rounded-full"
/> />
<span className="text-muted-foreground"> <span className="text-muted-foreground">en attente</span>
{t.playerCards.waiting}
</span>
</span> </span>
))} ))}
</motion.div> </motion.div>

View file

@ -2,7 +2,6 @@ import { useState } from "react"
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" import { Avatar } from "@/components/avatar"
import { useI18n } from "@/i18n/context"
const inputClass = const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
@ -10,7 +9,6 @@ const inputClass =
/** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */ /** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */
export function PseudoScreen({ code }: { code: string }) { export function PseudoScreen({ code }: { code: string }) {
const setName = useRoomStore((s) => s.setName) const setName = useRoomStore((s) => s.setName)
const { t } = useI18n()
const [name, setName_] = useState("") const [name, setName_] = useState("")
const canSubmit = name.trim().length > 0 const canSubmit = name.trim().length > 0
@ -18,16 +16,16 @@ export function PseudoScreen({ code }: { code: string }) {
<div className="flex min-h-svh items-center justify-center p-6"> <div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-xs flex-col items-center gap-5"> <div className="flex w-full max-w-xs flex-col items-center gap-5">
<p className="text-muted-foreground text-xs uppercase"> <p className="text-muted-foreground text-xs uppercase">
{t.pseudo.room(code)} Room {code}
</p> </p>
<Avatar <Avatar
seed={name.trim() || "?"} seed={name.trim() || "?"}
className="size-28 ring-2 ring-primary/30" className="size-28 ring-2 ring-primary/30"
/> />
<p className="font-heading text-lg font-bold">{t.pseudo.choose}</p> <p className="font-heading text-lg font-bold">Choisis ton pseudo</p>
<input <input
className={inputClass} className={inputClass}
placeholder={t.pseudo.placeholder} placeholder="Ton pseudo"
value={name} value={name}
maxLength={24} maxLength={24}
autoFocus autoFocus
@ -39,7 +37,7 @@ export function PseudoScreen({ code }: { code: string }) {
disabled={!canSubmit} disabled={!canSubmit}
onClick={() => setName(name)} onClick={() => setName(name)}
> >
{t.pseudo.enter} Entrer dans la room
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -8,7 +8,6 @@ import type {
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 { Countdown } from "@/components/countdown" import { Countdown } from "@/components/countdown"
import { useI18n, type Dict } from "@/i18n/context"
const inputClass = const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
@ -25,10 +24,11 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const vote = useRoomStore((s) => s.vote) const vote = useRoomStore((s) => s.vote)
const voteText = useRoomStore((s) => s.voteText) const voteText = useRoomStore((s) => s.voteText)
const { t } = useI18n()
if (!round) { if (!round) {
return <p className="text-muted-foreground text-sm">{t.common.loading}</p> return (
<p className="text-muted-foreground text-sm">Préparation de la manche</p>
)
} }
const question = round.payload as QuizQuestionPayload const question = round.payload as QuizQuestionPayload
@ -62,7 +62,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="flex w-full max-w-lg flex-col gap-5"> <div className="flex w-full max-w-lg flex-col gap-5">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase"> <span className="text-muted-foreground text-xs uppercase">
{t.game.question(snapshot.currentRound + 1, snapshot.totalRounds)} Question {snapshot.currentRound + 1} / {snapshot.totalRounds}
{question.category ? ` · ${question.category}` : ""} {question.category ? ` · ${question.category}` : ""}
</span> </span>
{!showReveal && ( {!showReveal && (
@ -92,7 +92,10 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
)} )}
{isText ? ( {isText ? (
<FreeAnswer disabled={showReveal || hasVoted} onSubmit={voteText} t={t} /> <FreeAnswer
disabled={showReveal || hasVoted}
onSubmit={voteText}
/>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{(question.choices ?? []).map((choice, index) => ( {(question.choices ?? []).map((choice, index) => (
@ -111,13 +114,13 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
{!showReveal && {!showReveal &&
(hasVoted ? ( (hasVoted ? (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
{t.game.answerSent} Réponse envoyée en attente des autres
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
</p> </p>
) : ( ) : (
voteProgress && ( voteProgress && (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
{t.game.answeredCount(voteProgress.count, voteProgress.total)} {voteProgress.count}/{voteProgress.total} ont répondu
</p> </p>
) )
))} ))}
@ -126,7 +129,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
{isText && ( {isText && (
<p className="text-sm"> <p className="text-sm">
<span className="text-muted-foreground">{t.game.answer} </span> <span className="text-muted-foreground">Réponse : </span>
<span className="font-medium">{truth?.answer}</span> <span className="font-medium">{truth?.answer}</span>
</p> </p>
)} )}
@ -134,10 +137,10 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`} className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
> >
{myResult?.correct {myResult?.correct
? t.game.correct ? "Bonne réponse ! 🎉"
: !hasVoted : !hasVoted
? t.game.noAnswer ? "Pas de réponse 😴"
: t.game.wrong} : "Raté 💥"}
</p> </p>
</div> </div>
)} )}
@ -190,25 +193,23 @@ function ImageReveal({
function FreeAnswer({ function FreeAnswer({
disabled, disabled,
onSubmit, onSubmit,
t,
}: { }: {
disabled: boolean disabled: boolean
onSubmit: (text: string) => void onSubmit: (text: string) => void
t: Dict
}) { }) {
const [text, setText] = useState("") const [text, setText] = useState("")
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
className={inputClass} className={inputClass}
placeholder={t.game.yourAnswer} placeholder="Ta réponse"
value={text} value={text}
disabled={disabled} disabled={disabled}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)} onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)}
/> />
<Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}> <Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}>
{t.game.validate} Valider
</Button> </Button>
</div> </div>
) )

View file

@ -1,18 +1,11 @@
import { useState } from "react" import { useState } from "react"
import { Check, Copy, Link2 } from "lucide-react" import { Check, Copy, Link2 } from "lucide-react"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { useI18n } from "@/i18n/context"
type Copied = "code" | "link" | null type Copied = "code" | "link" | null
/** Code de room copiable + bouton pour copier le lien d'invitation. */ /** Code de room copiable + bouton pour copier le lien d'invitation. */
export function RoomCode({ code }: { code: string }) { export function RoomCode({ code }: { code: string }) {
const { t } = useI18n()
const [copied, setCopied] = useState<Copied>(null) const [copied, setCopied] = useState<Copied>(null)
async function copy(what: Copied, text: string) { async function copy(what: Copied, text: string) {
@ -29,43 +22,35 @@ export function RoomCode({ code }: { code: string }) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Tooltip> <button
<TooltipTrigger asChild> onClick={() => copy("code", code)}
<button title="Copier le code"
onClick={() => copy("code", code)} className="group flex items-center gap-2 text-left"
className="group flex items-center gap-2 text-left" >
> <span>
<span> <span className="text-muted-foreground text-xs uppercase">
<span className="text-muted-foreground text-xs uppercase"> Code room
{t.roomCode.label} </span>
</span> <span className="font-heading block text-2xl font-bold tracking-widest">
<span className="font-heading block text-2xl font-bold tracking-widest"> {code}
{code} </span>
</span> </span>
</span> {copied === "code" ? (
{copied === "code" ? ( <Check className="size-4 text-green-500" />
<Check className="size-4 text-green-500" /> ) : (
) : ( <Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" /> )}
)} </button>
</button>
</TooltipTrigger>
<TooltipContent>{t.roomCode.copyCode}</TooltipContent>
</Tooltip>
<Tooltip> <Button
<TooltipTrigger asChild> size="sm"
<Button size="sm" variant="secondary" onClick={() => copy("link", link)}> variant="secondary"
{copied === "link" ? ( onClick={() => copy("link", link)}
<Check className="text-green-500" /> title="Copier le lien d'invitation"
) : ( >
<Link2 /> {copied === "link" ? <Check className="text-green-500" /> : <Link2 />}
)} {copied === "link" ? "Copié" : "Lien"}
{copied === "link" ? t.roomCode.copied : t.roomCode.link} </Button>
</Button>
</TooltipTrigger>
<TooltipContent>{t.roomCode.copyLink}</TooltipContent>
</Tooltip>
</div> </div>
) )
} }

View file

@ -1,7 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { useI18n } from "@/i18n/context"
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */ /** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
export type TransitionKind = "quiz" | "image" | "blindtest" export type TransitionKind = "quiz" | "image" | "blindtest"
@ -73,7 +72,6 @@ export function RoundTransition({
index: number index: number
modeChanged: boolean modeChanged: boolean
}) { }) {
const { t } = useI18n()
const [show, setShow] = useState(true) const [show, setShow] = useState(true)
const theme = THEME[kind] const theme = THEME[kind]
const [media] = useState(() => { const [media] = useState(() => {
@ -144,7 +142,7 @@ export function RoundTransition({
) : ( ) : (
<> <>
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow"> <span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
{t.transition.kind[kind]} {theme.kind}
</span> </span>
<motion.span <motion.span
className={`font-heading text-[26vmin] leading-none font-black ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`} className={`font-heading text-[26vmin] leading-none font-black ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
@ -165,7 +163,7 @@ export function RoundTransition({
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
className="font-heading -rotate-3 text-3xl font-black text-white italic" className="font-heading -rotate-3 text-3xl font-black text-white italic"
> >
{t.transition.ready} PRÊT ?!
</motion.span> </motion.span>
</motion.div> </motion.div>
)} )}

View file

@ -1,36 +0,0 @@
import { createContext, useContext } from "react"
import { fr } from "./fr"
export type Lang = "fr" | "en"
/** Forme du dictionnaire, dérivée du FR (source de vérité). */
export type Dict = typeof fr
export const LANG_KEY = "nerdware-lang"
export function detectLang(): Lang {
try {
const stored = localStorage.getItem(LANG_KEY)
if (stored === "fr" || stored === "en") {
return stored
}
} catch {
// localStorage indisponible : on retombe sur la langue du navigateur
}
return navigator.language?.toLowerCase().startsWith("fr") ? "fr" : "en"
}
export interface I18nContextValue {
lang: Lang
t: Dict
setLang: (lang: Lang) => void
}
export const I18nContext = createContext<I18nContextValue | null>(null)
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext)
if (!ctx) {
throw new Error("useI18n doit être utilisé dans <I18nProvider>")
}
return ctx
}

View file

@ -1,180 +0,0 @@
// Dictionnaire EN. Doit respecter la forme de `fr` (Dict) — sinon erreur TS.
import type { Dict } from "./context"
export const en: Dict = {
common: {
you: "(you)",
back: "Back to home",
backShort: "Back",
loading: "Loading…",
error: "Error",
},
home: {
tagline: "Geek culture party game",
create: "Create a room",
orJoin: "or join",
codePlaceholder: "CODE",
join: "Join",
connecting: "Connecting to server…",
},
pseudo: {
room: (code: string) => `Room ${code}`,
choose: "Choose your nickname",
placeholder: "Your nickname",
enter: "Enter the room",
},
joinPage: {
connecting: (code: string) => `Joining room ${code}`,
notFound: "Room not found.",
},
room: {
notFound: "Room not found or session lost.",
connected: "Connected",
disconnected: "Disconnected",
leave: "Leave",
},
roomCode: {
label: "Room code",
copyCode: "Copy code",
copyLink: "Copy invite link",
link: "Link",
copied: "Copied",
},
lobby: {
players: (n: number) => `Players (${n})`,
host: "Host",
bot: "🤖 Bot",
offline: "offline",
bots: "Bots",
settings: "Game settings",
gameMode: "Game mode",
blindtestLockHint: "3 players incl. 2 real ones (a bot can't DJ)",
blindtestUnlock:
"Blindtest unlocks at 3 players incl. 2 real ones (a bot can't DJ).",
subModes: "Included sub-modes",
quizCount: "Quiz questions",
imageCount: "Images to guess",
imageHint: "Requires “Image” questions created in the back-office.",
categories: "Categories",
allCategories: "All",
botLevel: "🤖 Bot difficulty",
blindtestMode: "Blindtest mode",
tracksPerPlayer: "Tracks per player",
launch: (n: number) => `Start (${n} rounds)`,
launching: "Starting…",
waitTracks: (n: number) =>
`Waiting for everyone to submit their ${n} track(s).`,
waitHost: "Waiting for the host to start…",
tracksSubmitted: (n: number) => ` (${n} tracks submitted)`,
prevGames: (n: number) => `Previous games (${n})`,
myTracks: (count: number, max: number) => `Your tracks (${count}/${max})`,
youtubePlaceholder: "YouTube link",
add: "Add",
openYoutube: "Open on YouTube",
remove: "Remove",
rejected: "Rejected",
},
gameTypes: {
mixed: "Mixed",
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
mixModes: {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
blindtestModes: {
title_artist: "Title & artist",
who_added: "Who added it?",
mixed: "Mixed",
},
botDifficulty: {
easy: "Easy",
normal: "Normal",
hard: "Hard",
},
game: {
question: (i: number, n: number) => `Question ${i} / ${n}`,
blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`,
answerSent: "Answer sent — waiting for the others",
answeredCount: (count: number, total: number) => `${count}/${total} answered`,
answer: "Answer:",
correct: "Correct! 🎉",
noAnswer: "No answer 😴",
wrong: "Wrong 💥",
yourAnswer: "Your answer",
validate: "Submit",
},
blindtest: {
djHint: "You're the DJ 🎧 — control playback (and vote like everyone)",
play: "Play",
pause: "Pause",
restart: "Start",
yourTrack: "It's your track!",
yourTrackTitleArtist: "You don't vote. Enjoy while the others search.",
yourTrackWhoAdded:
"You don't vote — your goal: nobody guesses you added it. +50 per fooled player.",
titlePlaceholder: "Title",
artistPlaceholder: "Artist",
whoAdded: "Who added it?",
vote: "Vote",
pickPlayer: "Pick a player",
revealTitle: "Title",
revealArtist: "Artist",
revealAddedBy: "Added by",
points: (n: number) => `+${n} points 🎉`,
submit: "Submit my answer",
},
playerCards: {
ready: "Ready",
waiting: "waiting",
},
transition: {
kind: {
quiz: "Question",
image: "Image",
blindtest: "Track",
},
ready: "READY ?!",
},
end: {
over: "Game over",
winner: (name: string) => `🏆 ${name} wins!`,
copy: "Copy results",
copied: "Copied!",
replay: "Play again",
waitHostReplay: "Waiting for the host to start a new game…",
leave: "Leave",
resultsTitle: "NerdWare — Results",
ranking: "🏆 Ranking",
rounds: "Rounds",
tracksTitle: "Tracks from the game",
playlist: "Playlist",
addedBy: (name: string) => `added by ${name}`,
searchSpotify: "Search on Spotify",
openYoutube: "Open on YouTube",
awards: "Awards",
awardPerfect: "Flawless",
awardBrain: "The brain",
awardFastest: "The fastest",
awardDecoy: "Master of deception",
awardBoulet: "The dud",
recapTitle: (n: number) => `Rounds recap (${n})`,
noAnswer: "No answer",
you: " (you)",
},
errors: {
ROOM_NOT_FOUND: "This room doesn't exist.",
ROOM_IN_PROGRESS: "The game has already started.",
ALREADY_STARTED: "Game already started.",
NO_ROUNDS: "No round configured.",
NEED_THREE: "Blindtest needs at least 3 players incl. 2 real ones.",
TRACKS_PENDING: "Not all players have submitted their tracks yet.",
CODE_EXHAUSTED: "Couldn't generate a room code.",
REJOIN_FAILED: "Reconnection failed.",
UNKNOWN_MODE: "Round unavailable.",
},
}

View file

@ -1,10 +0,0 @@
import type { Dict } from "./context"
/** Traduit une erreur serveur via son code, sinon retombe sur son message. */
export function errorMessage(t: Dict, err: unknown): string {
const e = err as { code?: string; message?: string } | null
if (e?.code && e.code in t.errors) {
return t.errors[e.code as keyof typeof t.errors]
}
return e?.message ?? t.common.error
}

View file

@ -1,179 +0,0 @@
// Dictionnaire FR — source de vérité de la forme du dictionnaire (voir en.ts).
export const fr = {
common: {
you: "(toi)",
back: "Retour à l'accueil",
backShort: "Retour",
loading: "Chargement…",
error: "Erreur",
},
home: {
tagline: "Party game culture geek",
create: "Créer une room",
orJoin: "ou rejoindre",
codePlaceholder: "CODE",
join: "Rejoindre",
connecting: "Connexion au serveur…",
},
pseudo: {
room: (code: string) => `Room ${code}`,
choose: "Choisis ton pseudo",
placeholder: "Ton pseudo",
enter: "Entrer dans la room",
},
joinPage: {
connecting: (code: string) => `Connexion à la room ${code}`,
notFound: "Room introuvable.",
},
room: {
notFound: "Room introuvable ou session perdue.",
connected: "Connecté",
disconnected: "Déconnecté",
leave: "Quitter",
},
roomCode: {
label: "Code room",
copyCode: "Copier le code",
copyLink: "Copier le lien d'invitation",
link: "Lien",
copied: "Copié",
},
lobby: {
players: (n: number) => `Joueurs (${n})`,
host: "Hôte",
bot: "🤖 Bot",
offline: "hors ligne",
bots: "Bots",
settings: "Réglages de la partie",
gameMode: "Mode de jeu",
blindtestLockHint: "3 joueurs dont 2 réels (un bot ne peut pas être DJ)",
blindtestUnlock:
"Le blindtest se débloque à 3 joueurs dont 2 réels (un bot ne peut pas être DJ).",
subModes: "Sous-modes inclus",
quizCount: "Questions de quiz",
imageCount: "Images à deviner",
imageHint: "Nécessite des questions « Image » créées dans le back-office.",
categories: "Catégories",
allCategories: "Toutes",
botLevel: "🤖 Niveau des bots",
blindtestMode: "Mode blindtest",
tracksPerPlayer: "Titres par joueur",
launch: (n: number) => `Lancer (${n} manches)`,
launching: "Lancement…",
waitTracks: (n: number) =>
`En attente que tout le monde ait soumis ses ${n} titre(s).`,
waitHost: "En attente du lancement par l'hôte…",
tracksSubmitted: (n: number) => ` (${n} titres soumis)`,
prevGames: (n: number) => `Parties précédentes (${n})`,
myTracks: (count: number, max: number) => `Tes titres (${count}/${max})`,
youtubePlaceholder: "Lien YouTube",
add: "Ajouter",
openYoutube: "Ouvrir sur YouTube",
remove: "Supprimer",
rejected: "Refusé",
},
gameTypes: {
mixed: "Mixte",
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
mixModes: {
quiz: "Quiz",
image: "Images",
blindtest: "Blindtest",
},
blindtestModes: {
title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?",
mixed: "Mixte",
},
botDifficulty: {
easy: "Facile",
normal: "Normal",
hard: "Difficile",
},
game: {
question: (i: number, n: number) => `Question ${i} / ${n}`,
blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`,
answerSent: "Réponse envoyée — en attente des autres",
answeredCount: (count: number, total: number) => `${count}/${total} ont répondu`,
answer: "Réponse :",
correct: "Bonne réponse ! 🎉",
noAnswer: "Pas de réponse 😴",
wrong: "Raté 💥",
yourAnswer: "Ta réponse",
validate: "Valider",
},
blindtest: {
djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)",
play: "Play",
pause: "Pause",
restart: "Début",
yourTrack: "C'est ton titre !",
yourTrackTitleArtist:
"Tu ne votes pas. Savoure pendant que les autres cherchent.",
yourTrackWhoAdded:
"Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé.",
titlePlaceholder: "Titre",
artistPlaceholder: "Artiste",
whoAdded: "Qui l'a ajouté ?",
vote: "Voter",
pickPlayer: "Choisis un joueur",
revealTitle: "Titre",
revealArtist: "Artiste",
revealAddedBy: "Ajouté par",
points: (n: number) => `+${n} points 🎉`,
submit: "Valider ma réponse",
},
playerCards: {
ready: "Prêt",
waiting: "en attente",
},
transition: {
kind: {
quiz: "Question",
image: "Image",
blindtest: "Titre",
},
ready: "PRÊT ?!",
},
end: {
over: "Partie terminée",
winner: (name: string) => `🏆 ${name} l'emporte !`,
copy: "Copier les résultats",
copied: "Copié !",
replay: "Rejouer",
waitHostReplay: "En attente que l'hôte relance une partie…",
leave: "Quitter",
resultsTitle: "NerdWare — Résultats",
ranking: "🏆 Classement",
rounds: "Manches",
tracksTitle: "Les musiques de la partie",
playlist: "Playlist",
addedBy: (name: string) => `ajouté par ${name}`,
searchSpotify: "Chercher sur Spotify",
openYoutube: "Ouvrir sur YouTube",
awards: "Récompenses",
awardPerfect: "Sans-faute",
awardBrain: "Le cerveau",
awardFastest: "Le plus rapide",
awardDecoy: "Roi de la tromperie",
awardBoulet: "Le boulet",
recapTitle: (n: number) => `Récapitulatif des manches (${n})`,
noAnswer: "Aucune réponse",
you: " (toi)",
},
errors: {
ROOM_NOT_FOUND: "Cette room n'existe pas.",
ROOM_IN_PROGRESS: "La partie a déjà commencé.",
ALREADY_STARTED: "Partie déjà lancée.",
NO_ROUNDS: "Aucune épreuve configurée.",
NEED_THREE: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.",
TRACKS_PENDING: "Tous les joueurs n'ont pas encore soumis leurs titres.",
CODE_EXHAUSTED: "Impossible de générer un code de room.",
REJOIN_FAILED: "Reconnexion impossible.",
UNKNOWN_MODE: "Épreuve indisponible.",
},
}

View file

@ -1,25 +0,0 @@
import { useI18n, type Lang } from "./context"
const LANGS: Lang[] = ["fr", "en"]
/** Petit sélecteur FR / EN. */
export function LanguageToggle({ className = "" }: { className?: string }) {
const { lang, setLang } = useI18n()
return (
<div className={`flex gap-1 ${className}`}>
{LANGS.map((l) => (
<button
key={l}
onClick={() => setLang(l)}
className={`rounded-md px-2 py-1 text-xs font-semibold uppercase transition-colors ${
lang === l
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{l}
</button>
))}
</div>
)
}

View file

@ -1,30 +0,0 @@
import { useState, type ReactNode } from "react"
import { fr } from "./fr"
import { en } from "./en"
import {
I18nContext,
LANG_KEY,
detectLang,
type Dict,
type Lang,
} from "./context"
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(detectLang)
const dicts: Record<Lang, Dict> = { fr, en }
function setLang(next: Lang) {
try {
localStorage.setItem(LANG_KEY, next)
} catch {
// ignore
}
setLangState(next)
}
return (
<I18nContext.Provider value={{ lang, t: dicts[lang], setLang }}>
{children}
</I18nContext.Provider>
)
}

View file

@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import "@workspace/ui/globals.css" import "@workspace/ui/globals.css"
import { App } from "./App.tsx" import { App } from "./App.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx" import { ThemeProvider } from "@/components/theme-provider.tsx"
import { I18nProvider } from "@/i18n/provider"
const queryClient = new QueryClient() const queryClient = new QueryClient()
@ -13,9 +12,7 @@ createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<I18nProvider> <App />
<App />
</I18nProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode> </StrictMode>

View file

@ -4,9 +4,6 @@ import { motion } from "framer-motion"
import { LogIn, Sparkles } from "lucide-react" import { LogIn, Sparkles } from "lucide-react"
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 { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error"
import { LanguageToggle } from "@/i18n/language-toggle"
const inputClass = const inputClass =
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
@ -23,7 +20,6 @@ const FLOATERS = [
export function HomePage() { export function HomePage() {
const [, navigate] = useLocation() const [, navigate] = useLocation()
const { t } = useI18n()
const createRoom = useRoomStore((s) => s.createRoom) const createRoom = useRoomStore((s) => s.createRoom)
const joinRoom = useRoomStore((s) => s.joinRoom) const joinRoom = useRoomStore((s) => s.joinRoom)
const connected = useRoomStore((s) => s.connected) const connected = useRoomStore((s) => s.connected)
@ -39,7 +35,7 @@ export function HomePage() {
const { roomCode } = await createRoom() const { roomCode } = await createRoom()
navigate(`/room/${roomCode}`) navigate(`/room/${roomCode}`)
} catch (err) { } catch (err) {
setError(errorMessage(t, err)) setError((err as { message?: string }).message ?? "Erreur")
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -52,7 +48,7 @@ export function HomePage() {
const { roomCode } = await joinRoom(code.trim().toUpperCase()) const { roomCode } = await joinRoom(code.trim().toUpperCase())
navigate(`/room/${roomCode}`) navigate(`/room/${roomCode}`)
} catch (err) { } catch (err) {
setError(errorMessage(t, err)) setError((err as { message?: string }).message ?? "Erreur")
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -60,7 +56,6 @@ export function HomePage() {
return ( return (
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6"> <div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
<LanguageToggle className="absolute top-4 right-4 z-10" />
{/* Halo animé */} {/* Halo animé */}
<motion.div <motion.div
aria-hidden aria-hidden
@ -103,7 +98,7 @@ export function HomePage() {
NerdWare NerdWare
</motion.h1> </motion.h1>
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm"> <p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm">
<Sparkles className="size-3.5" /> {t.home.tagline} <Sparkles className="size-3.5" /> Party game culture geek
</p> </p>
</header> </header>
@ -114,20 +109,20 @@ export function HomePage() {
disabled={!connected || busy} disabled={!connected || busy}
onClick={handleCreate} onClick={handleCreate}
> >
<Sparkles /> {t.home.create} <Sparkles /> Créer une room
</Button> </Button>
</motion.div> </motion.div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
<span className="text-muted-foreground text-xs">{t.home.orJoin}</span> <span className="text-muted-foreground text-xs">ou rejoindre</span>
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`} className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`}
placeholder={t.home.codePlaceholder} placeholder="CODE"
value={code} value={code}
maxLength={4} maxLength={4}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
@ -138,13 +133,13 @@ export function HomePage() {
disabled={!connected || busy || code.trim().length === 0} disabled={!connected || busy || code.trim().length === 0}
onClick={handleJoin} onClick={handleJoin}
> >
<LogIn /> {t.home.join} <LogIn /> Rejoindre
</Button> </Button>
</div> </div>
{!connected && ( {!connected && (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
{t.home.connecting} Connexion au serveur
</p> </p>
)} )}
{error && ( {error && (

View file

@ -2,13 +2,10 @@ import { useEffect, useRef, useState } from "react"
import { Link, useLocation } from "wouter" import { Link, useLocation } from "wouter"
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 { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error"
/** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */ /** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */
export function JoinPage({ code }: { code: string }) { export function JoinPage({ code }: { code: string }) {
const [, navigate] = useLocation() const [, navigate] = useLocation()
const { t } = useI18n()
const joinRoom = useRoomStore((s) => s.joinRoom) const joinRoom = useRoomStore((s) => s.joinRoom)
const connected = useRoomStore((s) => s.connected) const connected = useRoomStore((s) => s.connected)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -21,8 +18,10 @@ export function JoinPage({ code }: { code: string }) {
tried.current = true tried.current = true
joinRoom(code) joinRoom(code)
.then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true })) .then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true }))
.catch((err) => setError(errorMessage(t, err))) .catch((err) =>
}, [connected, code, joinRoom, navigate, t]) setError((err as { message?: string }).message ?? "Room introuvable.")
)
}, [connected, code, joinRoom, navigate])
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center"> <div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
@ -30,12 +29,12 @@ export function JoinPage({ code }: { code: string }) {
<> <>
<p className="text-muted-foreground text-sm">{error}</p> <p className="text-muted-foreground text-sm">{error}</p>
<Link href="/"> <Link href="/">
<Button variant="secondary">{t.common.back}</Button> <Button variant="secondary">Retour à l'accueil</Button>
</Link> </Link>
</> </>
) : ( ) : (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{t.joinPage.connecting(code)} Connexion à la room {code}
</p> </p>
)} )}
</div> </div>

View file

@ -17,12 +17,9 @@ import { RoomCode } from "@/components/room-code"
import { Boom } from "@/components/boom" import { Boom } from "@/components/boom"
import { RoundTransition } from "@/components/round-transition" import { RoundTransition } from "@/components/round-transition"
import { PseudoScreen } from "@/components/pseudo-screen" import { PseudoScreen } from "@/components/pseudo-screen"
import { useI18n } from "@/i18n/context"
import { LanguageToggle } from "@/i18n/language-toggle"
export function RoomPage({ code }: { code: string }) { export function RoomPage({ code }: { code: string }) {
const [, navigate] = useLocation() const [, navigate] = useLocation()
const { t } = useI18n()
const snapshot = useRoomStore((s) => s.snapshot) const snapshot = useRoomStore((s) => s.snapshot)
const roomCode = useRoomStore((s) => s.roomCode) const roomCode = useRoomStore((s) => s.roomCode)
const leaveRoom = useRoomStore((s) => s.leaveRoom) const leaveRoom = useRoomStore((s) => s.leaveRoom)
@ -40,9 +37,11 @@ export function RoomPage({ code }: { code: string }) {
if (roomCode !== code) { if (roomCode !== code) {
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center"> <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> <p className="text-muted-foreground text-sm">
Room introuvable ou session perdue.
</p>
<Link href="/"> <Link href="/">
<Button variant="secondary">{t.common.back}</Button> <Button variant="secondary">Retour à l'accueil</Button>
</Link> </Link>
</div> </div>
) )
@ -57,7 +56,7 @@ export function RoomPage({ code }: { code: string }) {
if (!snapshot || snapshot.code !== code) { if (!snapshot || snapshot.code !== code) {
return ( return (
<div className="flex min-h-svh items-center justify-center p-6"> <div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">{t.common.loading}</p> <p className="text-muted-foreground text-sm">Chargement</p>
</div> </div>
) )
} }
@ -97,10 +96,9 @@ export function RoomPage({ code }: { code: string }) {
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{connected ? t.room.connected : t.room.disconnected} {connected ? "Connecté" : "Déconnecté"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<LanguageToggle />
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@ -109,7 +107,7 @@ export function RoomPage({ code }: { code: string }) {
navigate("/") navigate("/")
}} }}
> >
<LogOut /> {t.room.leave} <LogOut /> Quitter
</Button> </Button>
</div> </div>
</header> </header>