diff --git a/apps/web/package.json b/apps/web/package.json
index 2c7c96e..5d5458d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx
index fe53b7f..9f2012a 100644
--- a/apps/web/src/components/countdown.tsx
+++ b/apps/web/src/components/countdown.tsx
@@ -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 (
- {left}
+
+ {danger && 💣}
+ {left}
+
)
}
diff --git a/apps/web/src/components/player-cards.tsx b/apps/web/src/components/player-cards.tsx
new file mode 100644
index 0000000..353116f
--- /dev/null
+++ b/apps/web/src/components/player-cards.tsx
@@ -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 (
+
+
+ {ranked.map((p) => {
+ const score = scoreOf(p.id)
+ const isLeader = max > 0 && score === max
+ const isMe = p.id === playerId
+ return (
+
+ {isLeader && (
+
+
+
+ )}
+
+ {p.name}
+ {isMe && (toi)}
+
+
+ {score}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx
index d255a3b..cbc231a 100644
--- a/apps/web/src/components/quiz-view.tsx
+++ b/apps/web/src/components/quiz-view.tsx
@@ -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 && (
-
-
- {myResult?.correct
- ? "Bonne réponse ! 🎉"
- : myResult?.choiceIndex == null
- ? "Pas de réponse 😴"
- : "Raté 💥"}
-
-
-
+
+ {myResult?.correct
+ ? "Bonne réponse ! 🎉"
+ : myResult?.choiceIndex == null
+ ? "Pas de réponse 😴"
+ : "Raté 💥"}
+
)}
)
diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx
new file mode 100644
index 0000000..58142df
--- /dev/null
+++ b/apps/web/src/components/room-code.tsx
@@ -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 (
+
+ )
+}
diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx
index 949bd77..bb975dd 100644
--- a/apps/web/src/pages/room.tsx
+++ b/apps/web/src/pages/room.tsx
@@ -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 (
-
-
Code room
-
- {snapshot.code}
-
-
+
+ {inGame &&
}
+
{snapshot.status === "lobby" &&
}
- {(snapshot.status === "in_round" ||
- snapshot.status === "reveal" ||
- snapshot.status === "scores") &&
}
+ {inGame &&
}
{snapshot.status === "ended" &&
}
diff --git a/bun.lock b/bun.lock
index 515f883..8ff9b27 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],