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
7 changed files with 182 additions and 27 deletions
Showing only changes of commit f82c7d9700 - Show all commits

View file

@ -14,6 +14,7 @@
"dependencies": {
"@nerdware/shared": "workspace:*",
"@workspace/ui": "workspace:*",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",

View file

@ -1,10 +1,25 @@
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */
function secondsLeft(endsAt: number): number {
return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000))
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
/** Blanc → rouge sur les DANGER_FROM dernières secondes. */
function dangerColor(t: number): string {
const r = Math.round(lerp(255, 239, t))
const g = Math.round(lerp(255, 68, t))
const b = Math.round(lerp(255, 68, t))
return `rgb(${r}, ${g}, ${b})`
}
export function Countdown({ endsAt }: { endsAt: number }) {
const [left, setLeft] = useState(() => secondsLeft(endsAt))
@ -13,7 +28,35 @@ export function Countdown({ endsAt }: { endsAt: number }) {
return () => clearInterval(id)
}, [endsAt])
const danger = left <= DANGER_FROM && left > 0
// 0 (début du danger) → 1 (explosion imminente)
const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0
// Plus on approche de 0, plus le "battement" est ample et rapide.
const peak = 1.15 + intensity * 0.5
const wiggle = 4 + intensity * 12
const duration = 0.7 - intensity * 0.46
return (
<span className="font-heading text-2xl font-bold tabular-nums">{left}</span>
<motion.span
className="font-heading inline-flex items-center gap-1 text-2xl font-bold tabular-nums"
style={{ color: danger ? dangerColor(intensity) : undefined }}
animate={
danger
? {
scale: [1, peak, 0.92, peak * 0.97, 1],
rotate: [0, -wiggle, wiggle, -wiggle * 0.6, 0],
}
: { scale: 1, rotate: 0 }
}
transition={
danger
? { duration, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.2 }
}
>
{danger && <span aria-hidden>💣</span>}
{left}
</motion.span>
)
}

View file

@ -0,0 +1,69 @@
import { AnimatePresence, motion } from "framer-motion"
import { Crown } from "lucide-react"
import type { RoomSnapshot } from "@nerdware/shared"
interface PlayerCardsProps {
snapshot: RoomSnapshot
playerId: string | null
}
/** HUD persistant : une card par joueur avec son score, couronne au leader. */
export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) {
const scoreOf = (id: string) =>
snapshot.scores.find((s) => s.playerId === id)?.score ?? 0
const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id)))
const ranked = [...snapshot.players].sort(
(a, b) => scoreOf(b.id) - scoreOf(a.id)
)
return (
<div className="flex flex-wrap justify-center gap-2">
<AnimatePresence initial={false}>
{ranked.map((p) => {
const score = scoreOf(p.id)
const isLeader = max > 0 && score === max
const isMe = p.id === playerId
return (
<motion.div
key={p.id}
layout
transition={{ type: "spring", stiffness: 500, damping: 40 }}
className={`relative flex min-w-20 flex-col items-center gap-0.5 rounded-xl border px-3 py-2 ${
isLeader
? "border-amber-400/60 bg-amber-400/10"
: "border-input bg-muted/40"
} ${isMe ? "ring-primary ring-2" : ""} ${
p.connected ? "" : "opacity-40"
}`}
>
{isLeader && (
<motion.span
initial={{ scale: 0, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 20 }}
className="absolute -top-2.5 text-amber-400"
>
<Crown className="size-4 fill-amber-400" />
</motion.span>
)}
<span className="max-w-24 truncate text-xs font-medium">
{p.name}
{isMe && <span className="text-muted-foreground"> (toi)</span>}
</span>
<motion.span
key={score}
initial={{ scale: 1.4 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="font-heading text-lg font-bold tabular-nums"
>
{score}
</motion.span>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}

View file

@ -6,7 +6,6 @@ import type {
} from "@nerdware/shared"
import { useRoomStore } from "@/store/room"
import { Countdown } from "@/components/countdown"
import { Scoreboard } from "@/components/scoreboard"
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
@ -91,22 +90,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
))}
{showReveal && (
<div className="flex flex-col gap-3">
<p
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
>
{myResult?.correct
? "Bonne réponse ! 🎉"
: myResult?.choiceIndex == null
? "Pas de réponse 😴"
: "Raté 💥"}
</p>
<Scoreboard
scores={snapshot.scores}
snapshot={snapshot}
playerId={playerId}
/>
</div>
<p
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
>
{myResult?.correct
? "Bonne réponse ! 🎉"
: myResult?.choiceIndex == null
? "Pas de réponse 😴"
: "Raté 💥"}
</p>
)}
</div>
)

View file

@ -0,0 +1,39 @@
import { useState } from "react"
import { Check, Copy } from "lucide-react"
/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */
export function RoomCode({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// presse-papier indisponible (http non sécurisé) : on ignore silencieusement
}
}
return (
<button
onClick={copy}
title="Copier le code"
className="group flex items-center gap-2 text-left"
>
<span>
<span className="text-muted-foreground text-xs uppercase">
Code room
</span>
<span className="font-heading block text-2xl font-bold tracking-widest">
{code}
</span>
</span>
{copied ? (
<Check className="size-4 text-green-500" />
) : (
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
)}
</button>
)
}

View file

@ -4,9 +4,12 @@ import { useRoomStore } from "@/store/room"
import { LobbyView } from "@/components/lobby-view"
import { QuizView } from "@/components/quiz-view"
import { GameEndView } from "@/components/game-end-view"
import { PlayerCards } from "@/components/player-cards"
import { RoomCode } from "@/components/room-code"
export function RoomPage({ code }: { code: string }) {
const snapshot = useRoomStore((s) => s.snapshot)
const playerId = useRoomStore((s) => s.playerId)
const connected = useRoomStore((s) => s.connected)
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
@ -23,26 +26,27 @@ export function RoomPage({ code }: { code: string }) {
)
}
// Cards de scores visibles en permanence pendant le jeu (suivi continu).
const inGame =
snapshot.status === "in_round" ||
snapshot.status === "reveal" ||
snapshot.status === "scores"
return (
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
<div className="flex w-full max-w-md flex-col gap-6">
<header className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-xs uppercase">Code room</p>
<p className="font-heading text-2xl font-bold tracking-widest">
{snapshot.code}
</p>
</div>
<RoomCode code={snapshot.code} />
<span
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
title={connected ? "Connecté" : "Déconnecté"}
/>
</header>
{inGame && <PlayerCards snapshot={snapshot} playerId={playerId} />}
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
{(snapshot.status === "in_round" ||
snapshot.status === "reveal" ||
snapshot.status === "scores") && <QuizView snapshot={snapshot} />}
{inGame && <QuizView snapshot={snapshot} />}
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
</div>
</div>

View file

@ -37,6 +37,7 @@
"dependencies": {
"@nerdware/shared": "workspace:*",
"@workspace/ui": "workspace:*",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@ -872,6 +873,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
@ -1076,6 +1079,10 @@
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
"motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="],