release/0.1.0 #18

Merged
ayoub merged 68 commits from release/0.1.0 into main 2026-06-11 18:36:42 +00:00
8 changed files with 187 additions and 49 deletions
Showing only changes of commit 3f0cab8f5f - Show all commits

View file

@ -72,7 +72,18 @@ export class GameEngine implements RoomGameController {
}
this.room.status = "ended"
this.broadcastState()
this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() })
// Récap des titres blindtest joués (pour récupérer les liens YouTube).
const tracks = this.room.blindtestTracks.map((t) => ({
title: t.title,
artist: t.artist,
youtubeId: t.youtubeId,
url: t.url,
submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?",
}))
this.io.to(this.room.code).emit("game:end", {
finalScores: this.scoreboard(),
tracks: tracks.length > 0 ? tracks : undefined,
})
}
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */

View file

@ -295,7 +295,9 @@ function VoteForm({
)}
{needsWho && (
<div className="flex flex-wrap justify-center gap-2">
{snapshot.players.map((p) => (
{snapshot.players
.filter((p) => p.id !== playerId)
.map((p) => (
<button
key={p.id}
onClick={() => setGuessed(p.id)}
@ -307,9 +309,6 @@ function VoteForm({
>
<Avatar seed={p.name} className="size-5" />
{p.name}
{p.id === playerId && (
<span className="text-muted-foreground">(toi)</span>
)}
</button>
))}
</div>

View file

@ -1,6 +1,6 @@
import { Link } from "wouter"
import { motion } from "framer-motion"
import { Crown } from "lucide-react"
import { Crown, ExternalLink, Music } from "lucide-react"
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
@ -112,6 +112,7 @@ function PodiumColumn({
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId)
const finalScores = useRoomStore((s) => s.finalScores)
const gameTracks = useRoomStore((s) => s.gameTracks)
const reset = useRoomStore((s) => s.reset)
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId
@ -196,6 +197,46 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
</ol>
)}
{gameTracks && gameTracks.length > 0 && (
<section className="flex flex-col gap-2 text-left">
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> Les musiques de la partie
</h3>
<ul className="flex flex-col gap-2">
{gameTracks.map((t) => (
<li
key={t.youtubeId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
>
<img
src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
alt=""
className="h-10 w-16 shrink-0 rounded object-cover"
/>
<span className="min-w-0 flex-1">
<span className="line-clamp-1 text-xs font-medium">
{t.title}
</span>
<span className="text-muted-foreground line-clamp-1 text-[10px]">
ajouté par {t.submittedByName}
</span>
</span>
<a
href={t.url}
target="_blank"
rel="noreferrer"
title="Ouvrir sur YouTube"
>
<Button size="icon-sm" variant="secondary">
<ExternalLink />
</Button>
</a>
</li>
))}
</ul>
</section>
)}
<div className="flex flex-col gap-2">
{isHost ? (
<Button className="w-full" onClick={returnToLobby}>

View file

@ -1,5 +1,14 @@
import { useState } from "react"
import { Trash2 } from "lucide-react"
import {
Brain,
Headphones,
ListMusic,
Music,
Play,
Shuffle,
Trash2,
type LucideIcon,
} from "lucide-react"
import type {
BlindtestMode,
GameType,
@ -12,10 +21,10 @@ import { Avatar } from "@/components/avatar"
const QUESTION_OPTIONS = [3, 5, 10]
const TRACKS_OPTIONS = [1, 2, 3]
const GAME_TYPES: { value: GameType; label: string }[] = [
{ value: "mixed", label: "Mixte" },
{ value: "quiz", label: "Quiz" },
{ value: "blindtest", label: "Blindtest" },
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
{ value: "mixed", label: "Mixte", Icon: Shuffle },
{ value: "quiz", label: "Quiz", Icon: Brain },
{ value: "blindtest", label: "Blindtest", Icon: Headphones },
]
const MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste",
@ -128,18 +137,18 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost && (
<div className="flex flex-col gap-1">
<div className="flex gap-2">
{GAME_TYPES.map((t) => {
const needsThree = t.value !== "quiz" && !blindtestAvailable
{GAME_TYPES.map(({ value, label, Icon }) => {
const needsThree = value !== "quiz" && !blindtestAvailable
return (
<Button
key={t.value}
key={value}
className="flex-1"
variant={effectiveType === t.value ? "default" : "secondary"}
variant={effectiveType === value ? "default" : "secondary"}
disabled={needsThree}
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: t.value })}
onClick={() => updateSettings({ gameType: value })}
>
{t.label}
<Icon /> {label}
</Button>
)
})}
@ -154,7 +163,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost && showQuiz && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Questions de quiz</span>
<span className="flex items-center gap-1.5 text-sm font-medium">
<Brain className="size-4" /> Questions de quiz
</span>
<div className="flex gap-1">
{QUESTION_OPTIONS.map((n) => (
<Button
@ -173,7 +184,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost && showBlindtest && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Mode blindtest</span>
<span className="flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> Mode blindtest
</span>
<div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button
@ -189,7 +202,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Titres par joueur</span>
<span className="flex items-center gap-1.5 text-sm font-medium">
<ListMusic className="size-4" /> Titres par joueur
</span>
<div className="flex gap-1">
{TRACKS_OPTIONS.map((n) => (
<Button
@ -212,7 +227,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost ? (
<Button disabled={busy || !canStart} onClick={start}>
{busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
</Button>
) : (
<p className="text-muted-foreground text-center text-xs">

View file

@ -1,11 +1,23 @@
import { useState } from "react"
import { useLocation } from "wouter"
import { motion } from "framer-motion"
import { LogIn, Sparkles } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
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"
// Emojis geek qui flottent en fond.
const FLOATERS = [
{ emoji: "🎮", x: "8%", y: "18%", delay: 0 },
{ emoji: "🎧", x: "82%", y: "22%", delay: 0.6 },
{ emoji: "🧠", x: "15%", y: "72%", delay: 1.2 },
{ emoji: "👾", x: "78%", y: "70%", delay: 0.3 },
{ emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 },
{ emoji: "🎬", x: "60%", y: "82%", delay: 1.5 },
]
export function HomePage() {
const [, navigate] = useLocation()
const createRoom = useRoomStore((s) => s.createRoom)
@ -43,20 +55,63 @@ export function HomePage() {
}
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
{/* Halo animé */}
<motion.div
aria-hidden
className="pointer-events-none absolute -z-10 size-[120vmin] rounded-full bg-gradient-to-br from-fuchsia-500/20 via-purple-500/10 to-cyan-400/20 blur-3xl"
animate={{ scale: [1, 1.15, 1], rotate: [0, 30, 0] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Emojis flottants */}
{FLOATERS.map((f) => (
<motion.span
key={f.emoji}
aria-hidden
className="pointer-events-none absolute -z-10 text-4xl opacity-40 select-none sm:text-5xl"
style={{ left: f.x, top: f.y }}
animate={{ y: [0, -18, 0], rotate: [-8, 8, -8] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: f.delay,
}}
>
{f.emoji}
</motion.span>
))}
<motion.div
className="flex w-full max-w-sm flex-col gap-6"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<header className="text-center">
<h1 className="font-heading text-3xl font-bold tracking-tight">
<motion.h1
className="font-heading bg-gradient-to-r from-fuchsia-500 via-purple-500 to-cyan-400 bg-clip-text text-5xl font-black tracking-tight text-transparent"
initial={{ scale: 0.6, rotate: -6, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 14 }}
>
NerdWare
</h1>
<p className="text-muted-foreground text-sm">
Party game culture geek
</motion.h1>
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm">
<Sparkles className="size-3.5" /> Party game culture geek
</p>
</header>
<Button disabled={!connected || busy} onClick={handleCreate}>
Créer une room
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="lg"
className="w-full"
disabled={!connected || busy}
onClick={handleCreate}
>
<Sparkles /> Créer une room
</Button>
</motion.div>
<div className="flex items-center gap-2">
<div className="bg-border h-px flex-1" />
@ -66,8 +121,8 @@ export function HomePage() {
<div className="flex gap-2">
<input
className={`${inputClass} uppercase`}
placeholder="Code"
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`}
placeholder="CODE"
value={code}
maxLength={4}
onChange={(e) => setCode(e.target.value)}
@ -78,7 +133,7 @@ export function HomePage() {
disabled={!connected || busy || code.trim().length === 0}
onClick={handleJoin}
>
Rejoindre
<LogIn /> Rejoindre
</Button>
</div>
@ -90,7 +145,7 @@ export function HomePage() {
{error && (
<p className="text-destructive text-center text-sm">{error}</p>
)}
</div>
</motion.div>
</div>
)
}

View file

@ -2,6 +2,7 @@ import { create } from "zustand"
import {
DEFAULT_ROOM_SETTINGS,
type BlindtestAnswer,
type BlindtestTrackInfo,
type ErrorPayload,
type MediaControlPayload,
type MediaSyncPayload,
@ -49,6 +50,7 @@ interface RoomState {
myChoiceIndex: number | null
hasVoted: boolean
finalScores: PlayerScore[] | null
gameTracks: BlindtestTrackInfo[] | null
mediaSync: MediaSyncPayload | null
boomKey: number
roundKey: number
@ -85,6 +87,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
mediaSync: null,
boomKey: 0,
roundKey: 0,
@ -213,6 +216,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
mediaSync: null,
boomKey: 0,
roundKey: 0,
@ -234,6 +238,7 @@ socket.on("room:state", (snapshot) =>
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
mediaSync: null,
}
: { snapshot }
@ -265,6 +270,6 @@ socket.on("round:voteAck", (voteProgress) =>
)
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
socket.on("game:end", ({ finalScores }) =>
useRoomStore.setState({ finalScores })
socket.on("game:end", ({ finalScores, tracks }) =>
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null })
)

View file

@ -26,3 +26,12 @@ export interface BlindtestPlayerResult {
/** round:reveal → perPlayerResult : playerId → résultat. */
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>
/** Titre récapitulé en fin de partie (export / récupération des liens). */
export interface BlindtestTrackInfo {
title: string
artist: string
youtubeId: string
url: string
submittedByName: string
}

View file

@ -10,6 +10,7 @@ import type {
RoundConfig,
RoundType,
} from "./domain"
import type { BlindtestTrackInfo } from "./blindtest"
// --- Payloads ------------------------------------------------------------
@ -113,6 +114,8 @@ export interface ScoreUpdatePayload {
export interface GameEndPayload {
finalScores: PlayerScore[]
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
tracks?: BlindtestTrackInfo[]
spotifyExport?: unknown
}