Compare commits
No commits in common. "5d113e1d37cf126a55f7aa8d36097cb455e87317" and "5dcd7ac978211c7657c1feb711d1506ded26ceac" have entirely different histories.
5d113e1d37
...
5dcd7ac978
18 changed files with 223 additions and 737 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Chercher sur Spotify"
|
||||||
|
>
|
||||||
<Button size="icon-sm" variant="secondary">
|
<Button size="icon-sm" variant="secondary">
|
||||||
<SiSpotify color="default" />
|
<SiSpotify color="default" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</TooltipTrigger>
|
<a href={t.url} target="_blank" rel="noreferrer" title="Ouvrir sur YouTube">
|
||||||
<TooltipContent>{t.end.searchSpotify}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a href={track.url} target="_blank" rel="noreferrer">
|
|
||||||
<Button size="icon-sm" variant="secondary">
|
<Button size="icon-sm" variant="secondary">
|
||||||
<SiYoutube color="default" />
|
<SiYoutube color="default" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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={
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
<a
|
||||||
href={`https://www.youtube.com/watch?v=${track.youtubeId}`}
|
href={`https://www.youtube.com/watch?v=${t.youtubeId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
title="Ouvrir sur YouTube"
|
||||||
>
|
>
|
||||||
<Button size="icon-sm" variant="secondary">
|
<Button size="icon-sm" variant="secondary">
|
||||||
<SiYoutube color="default" />
|
<SiYoutube color="default" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t.lobby.openYoutube}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => remove(track.trackId)}
|
onClick={() => remove(t.trackId)}
|
||||||
|
title="Supprimer"
|
||||||
>
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
</Button>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,15 +22,14 @@ export function RoomCode({ code }: { code: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => copy("code", code)}
|
onClick={() => copy("code", code)}
|
||||||
|
title="Copier le 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">
|
||||||
{t.roomCode.label}
|
Code room
|
||||||
</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}
|
||||||
|
|
@ -49,23 +41,16 @@ export function RoomCode({ code }: { code: string }) {
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue