Merge pull request 'release/0.1.1' (#20) from release/0.1.1 into main
Reviewed-on: #20
This commit is contained in:
commit
60d0c8f3fe
28 changed files with 325 additions and 64 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -4,6 +4,30 @@ Toutes les évolutions notables de NerdWare. Format basé sur
|
|||
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
|
||||
[SemVer](https://semver.org/lang/fr/).
|
||||
|
||||
## [0.1.1] — 2026-06-12
|
||||
|
||||
Correctifs et améliorations issus des premières parties entre amis.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **DB** : connexion via variables `PG*` (au lieu d'un DSN inline) — robuste aux
|
||||
caractères spéciaux dans le mot de passe Postgres.
|
||||
- **Blindtest** : son débloqué côté non-DJ via un geste « Activer le son »
|
||||
(politique d'autoplay) + `origin`/`enablejsapi` du lecteur ; état de vote
|
||||
masqué en mode « qui l'a ajouté » (le contributeur ne se trahit plus).
|
||||
- **Blindtest** : liste de ses titres rendue depuis le serveur (autoritaire) —
|
||||
fin du « 3/3 mais 2 affichés » après remount/reconnexion.
|
||||
- **Quiz** : le champ de réponse libre se réinitialise entre les questions.
|
||||
- **Décompte** : synchro d'horloge serveur → le timer est identique sur tous les
|
||||
appareils (fini les écarts dus au décalage d'horloge).
|
||||
|
||||
### Ajouté / changé
|
||||
|
||||
- **Blindtest** : le DJ peut être n'importe qui, y compris le contributeur ;
|
||||
manches blindtest portées à 90 s.
|
||||
- **Volume** audio réglable par joueur (mémorisé).
|
||||
- **Thème** clair / sombre / système (sélecteur sur l'accueil et la room).
|
||||
|
||||
## [0.1.0] — 2026-06-11
|
||||
|
||||
Première version déployable. Cycle de jeu temps réel complet (lobby → manches →
|
||||
|
|
@ -31,4 +55,5 @@ fin), trois modes, back-office et internationalisation.
|
|||
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
|
||||
prêt pour Dokploy.
|
||||
|
||||
[0.1.1]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.1
|
||||
[0.1.0]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@nerdware/server",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
|||
21
apps/server/src/db/client.ts
Normal file
21
apps/server/src/db/client.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Création du client postgres-js. Deux modes :
|
||||
// 1. DATABASE_URL (DSN complet) — pratique en dev.
|
||||
// 2. Variables PG* (PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE) — recommandé en
|
||||
// prod : le mot de passe est passé tel quel (pas d'encodage d'URL à gérer).
|
||||
// postgres-js lit automatiquement les PG* de l'environnement quand on ne lui
|
||||
// passe pas de DSN.
|
||||
|
||||
import postgres from "postgres"
|
||||
import { env } from "../env"
|
||||
|
||||
export const hasDbConfig = !!env.databaseUrl || !!process.env.PGHOST
|
||||
|
||||
export function createSqlClient(opts: { max?: number } = {}) {
|
||||
if (env.databaseUrl) {
|
||||
return postgres(env.databaseUrl, opts)
|
||||
}
|
||||
if (process.env.PGHOST) {
|
||||
return postgres(opts)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -2,17 +2,16 @@
|
|||
// le jeu retombe sur la banque de questions en dur (dev sans infra).
|
||||
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import postgres from "postgres"
|
||||
import { env } from "../env"
|
||||
import { createSqlClient } from "./client"
|
||||
import * as schema from "./schema"
|
||||
|
||||
export type Database = ReturnType<typeof drizzle<typeof schema>>
|
||||
|
||||
function createDb(): Database | null {
|
||||
if (!env.databaseUrl) {
|
||||
const client = createSqlClient()
|
||||
if (!client) {
|
||||
return null
|
||||
}
|
||||
const client = postgres(env.databaseUrl)
|
||||
return drizzle(client, { schema })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@
|
|||
// bun run db:migrate
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator"
|
||||
import postgres from "postgres"
|
||||
import { env } from "../env"
|
||||
import { createSqlClient } from "./client"
|
||||
|
||||
if (!env.databaseUrl) {
|
||||
console.error("DATABASE_URL manquant — rien à migrer.")
|
||||
const client = createSqlClient({ max: 1 })
|
||||
if (!client) {
|
||||
console.error("Aucune config DB (DATABASE_URL ou PG*) — rien à migrer.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const client = postgres(env.databaseUrl, { max: 1 })
|
||||
const db = drizzle(client)
|
||||
|
||||
await migrate(db, { migrationsFolder: "./drizzle" })
|
||||
|
|
|
|||
|
|
@ -6,21 +6,19 @@
|
|||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import postgres from "postgres"
|
||||
import { env } from "../env"
|
||||
import { createSqlClient } from "./client"
|
||||
import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema"
|
||||
|
||||
if (!env.databaseUrl) {
|
||||
console.error("DATABASE_URL manquant — impossible de seeder.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151)
|
||||
const CATEGORY = "Pokémon"
|
||||
const PROMPT = "Quel est ce Pokémon ?"
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const client = postgres(env.databaseUrl, { max: 1 })
|
||||
const client = createSqlClient({ max: 1 })
|
||||
if (!client) {
|
||||
console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.")
|
||||
process.exit(1)
|
||||
}
|
||||
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
||||
|
||||
async function upsertCategory(name: string): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import postgres from "postgres"
|
||||
import { env } from "../env"
|
||||
import { createSqlClient } from "./client"
|
||||
import {
|
||||
quizCategory,
|
||||
quizQuestion,
|
||||
|
|
@ -14,12 +13,11 @@ import {
|
|||
} from "./schema"
|
||||
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
|
||||
|
||||
if (!env.databaseUrl) {
|
||||
console.error("DATABASE_URL manquant — impossible de seeder.")
|
||||
const client = createSqlClient({ max: 1 })
|
||||
if (!client) {
|
||||
console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const client = postgres(env.databaseUrl, { max: 1 })
|
||||
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
||||
|
||||
// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché).
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ describe("match", () => {
|
|||
})
|
||||
|
||||
describe("BlindtestRound", () => {
|
||||
test("start : le DJ est neutre (jamais le contributeur)", () => {
|
||||
test("start : le DJ est un humain connecté (le contributeur peut tomber DJ)", () => {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||
|
|
@ -53,10 +53,24 @@ describe("BlindtestRound", () => {
|
|||
|
||||
const round = new BlindtestRound()
|
||||
const start = round.start(room)
|
||||
expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice)
|
||||
// N'importe quel humain (Alice incluse), jamais un bot ni null.
|
||||
expect([a.id, b.id]).toContain(start.djId!)
|
||||
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
|
||||
})
|
||||
|
||||
test("start : un bot n'est jamais DJ", () => {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
rooms.addBot(room)
|
||||
rooms.addBot(room)
|
||||
room.blindtestTracks = [track(a.id)]
|
||||
prepareBlindtestForRoom(room)
|
||||
|
||||
const round = new BlindtestRound()
|
||||
// Alice est la seule humaine → DJ forcément Alice (les bots sont exclus).
|
||||
expect(round.start(room).djId).toBe(a.id)
|
||||
})
|
||||
|
||||
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
|
|
|
|||
|
|
@ -32,10 +32,14 @@ interface BlindtestRoundData {
|
|||
track: BlindtestTrack
|
||||
}
|
||||
|
||||
/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */
|
||||
function pickDj(room: ServerRoom, submittedBy: string): string | null {
|
||||
// Le blindtest dure plus longtemps (écoute + saisie).
|
||||
const BLINDTEST_DURATION_SEC = 90
|
||||
|
||||
/** Tire un DJ : n'importe quel humain connecté (y compris le contributeur, pour
|
||||
* ne pas donner d'« immunité » à celui qui n'est jamais tiré). */
|
||||
function pickDj(room: ServerRoom): string | null {
|
||||
const eligible = [...room.players.values()].filter(
|
||||
(p) => p.connected && !p.isBot && p.id !== submittedBy
|
||||
(p) => p.connected && !p.isBot
|
||||
)
|
||||
if (eligible.length === 0) {
|
||||
return null
|
||||
|
|
@ -77,13 +81,14 @@ export class BlindtestRound implements GameRound {
|
|||
|
||||
start(room: ServerRoom): RoundStart {
|
||||
const track = takeTrack(room)
|
||||
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle.
|
||||
const djId = track ? pickDj(room, track.submittedBy) : null
|
||||
// DJ = n'importe quel humain (le contributeur peut tomber DJ).
|
||||
const djId = track ? pickDj(room) : null
|
||||
const payload: BlindtestRoundPayload | null = track
|
||||
? { trackId: track.id, youtubeId: track.youtubeId }
|
||||
: null
|
||||
return {
|
||||
djId,
|
||||
durationSec: BLINDTEST_DURATION_SEC,
|
||||
payload,
|
||||
secretPlayerId: track?.submittedBy,
|
||||
data: track ? { track } : null,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,18 @@ export function registerRoomHandlers(
|
|||
io.to(room.code).emit("room:state", rooms.toSnapshot(room))
|
||||
}
|
||||
|
||||
// Renvoie au joueur SES propres titres (source de vérité, anti-désync client).
|
||||
const emitMyTracks = (room: ServerRoom, playerId: string) => {
|
||||
socket.emit("blindtest:myTracks", {
|
||||
tracks: room.blindtestTracks
|
||||
.filter((t) => t.submittedBy === playerId)
|
||||
.map((t) => ({ trackId: t.id, title: t.title, youtubeId: t.youtubeId })),
|
||||
})
|
||||
}
|
||||
|
||||
// Synchro d'horloge : le client mesure l'offset avec l'horloge serveur.
|
||||
socket.on("time:sync", (ack) => ack(Date.now()))
|
||||
|
||||
socket.on("room:create", (payload, ack) => {
|
||||
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom.
|
||||
const name = cleanName(payload?.playerName) ?? ""
|
||||
|
|
@ -111,6 +123,7 @@ export function registerRoomHandlers(
|
|||
})
|
||||
io.to(room.code).emit("player:rejoined", { playerId: player.id })
|
||||
broadcastState(room)
|
||||
emitMyTracks(room, player.id)
|
||||
// Re-pousse la manche en cours au socket reconnecté (si partie en cours).
|
||||
room.game?.resync(socket.id, player.id)
|
||||
})
|
||||
|
|
@ -293,6 +306,7 @@ export function registerRoomHandlers(
|
|||
title: meta.title,
|
||||
count: mine.length + 1,
|
||||
})
|
||||
emitMyTracks(room, playerId)
|
||||
broadcastState(room)
|
||||
})
|
||||
|
||||
|
|
@ -311,6 +325,7 @@ export function registerRoomHandlers(
|
|||
const removed = room.blindtestTracks.length < before
|
||||
ack({ ok: removed })
|
||||
if (removed) {
|
||||
emitMyTracks(room, playerId)
|
||||
broadcastState(room)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Disc3, Pause, Play, Rewind } from "lucide-react"
|
||||
import { Disc3, Pause, Play, Rewind, Volume2 } from "lucide-react"
|
||||
import type {
|
||||
BlindtestAnswer,
|
||||
BlindtestMode,
|
||||
|
|
@ -15,6 +15,7 @@ import { useYoutube, type YoutubeApi } from "@/lib/youtube"
|
|||
import { Countdown } from "@/components/countdown"
|
||||
import { Avatar } from "@/components/avatar"
|
||||
import { useI18n } from "@/i18n/context"
|
||||
import { serverNow } from "@/lib/clock"
|
||||
|
||||
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"
|
||||
|
|
@ -42,22 +43,56 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
|
||||
const showReveal = truth !== null
|
||||
|
||||
// Les navigateurs bloquent la lecture audible sans geste utilisateur : le non-DJ
|
||||
// doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé).
|
||||
const [audioArmed, setAudioArmed] = useState(false)
|
||||
|
||||
// Volume local (par joueur), mémorisé entre les manches/parties.
|
||||
const [volume, setVolume] = useState(() => {
|
||||
const v = Number(localStorage.getItem("nerdware-volume"))
|
||||
return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 100
|
||||
})
|
||||
useEffect(() => {
|
||||
if (ready) {
|
||||
api.setVolume(volume)
|
||||
}
|
||||
}, [ready, volume, api])
|
||||
function changeVolume(v: number) {
|
||||
setVolume(v)
|
||||
try {
|
||||
localStorage.setItem("nerdware-volume", String(v))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
|
||||
useEffect(() => {
|
||||
if (isDj || !ready || !mediaSync) {
|
||||
return
|
||||
}
|
||||
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
|
||||
const elapsed = (serverNow() - mediaSync.atServerTs) / 1000
|
||||
if (mediaSync.action === "play") {
|
||||
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
||||
if (audioArmed) {
|
||||
api.play()
|
||||
}
|
||||
} else if (mediaSync.action === "pause") {
|
||||
api.seek(mediaSync.positionSec)
|
||||
api.pause()
|
||||
} else {
|
||||
api.seek(mediaSync.positionSec)
|
||||
}
|
||||
}, [mediaSync, isDj, ready, api])
|
||||
}, [mediaSync, isDj, ready, api, audioArmed])
|
||||
|
||||
function armAudio() {
|
||||
setAudioArmed(true)
|
||||
if (mediaSync && mediaSync.action !== "pause") {
|
||||
const elapsed = (serverNow() - mediaSync.atServerTs) / 1000
|
||||
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
||||
}
|
||||
api.play()
|
||||
}
|
||||
|
||||
// Au reveal, on coupe le son.
|
||||
useEffect(() => {
|
||||
|
|
@ -98,8 +133,36 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{/* Non-DJ : geste requis pour débloquer l'audio (autoplay policy). */}
|
||||
{!showReveal && !isDj && !audioArmed && (
|
||||
<button
|
||||
onClick={armAudio}
|
||||
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-black/60 text-white backdrop-blur-sm"
|
||||
>
|
||||
<Volume2 className="size-10" />
|
||||
<span className="font-heading font-bold">{t.blindtest.enableSound}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!showReveal && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="text-muted-foreground size-4 shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||
className="accent-primary flex-1"
|
||||
aria-label={t.blindtest.volume}
|
||||
/>
|
||||
<span className="text-muted-foreground w-8 text-right text-xs tabular-nums">
|
||||
{volume}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDj && !showReveal && (
|
||||
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useEffect, useRef, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
import { serverNow } from "@/lib/clock"
|
||||
|
||||
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
||||
|
||||
/**
|
||||
* Secondes restantes. Avant `startsAt` (phase de préparation / transition),
|
||||
* on affiche la durée pleine sans décompter.
|
||||
* Secondes restantes, basées sur l'horloge serveur (serverNow) pour que tous les
|
||||
* clients affichent le même décompte. Avant `startsAt` (préparation), on affiche
|
||||
* la durée pleine sans décompter.
|
||||
*/
|
||||
function secondsLeft(startsAt: number, endsAt: number): number {
|
||||
const now = Date.now()
|
||||
const now = serverNow()
|
||||
if (now < startsAt) {
|
||||
return Math.ceil((endsAt - startsAt) / 1000)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -550,12 +550,6 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
)
|
||||
}
|
||||
|
||||
interface MyTrack {
|
||||
trackId: string
|
||||
title: string
|
||||
youtubeId: string
|
||||
}
|
||||
|
||||
function TrackSubmission({
|
||||
tracksPerPlayer,
|
||||
myCount,
|
||||
|
|
@ -565,11 +559,12 @@ function TrackSubmission({
|
|||
}) {
|
||||
const submitTrack = useRoomStore((s) => s.submitTrack)
|
||||
const removeTrack = useRoomStore((s) => s.removeTrack)
|
||||
// Source de vérité : le serveur renvoie nos titres (anti-désync au remount).
|
||||
const myTracks = useRoomStore((s) => s.myTracks)
|
||||
const { t } = useI18n()
|
||||
const [url, setUrl] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
const [tracks, setTracks] = useState<MyTrack[]>([])
|
||||
const quotaReached = myCount >= tracksPerPlayer
|
||||
|
||||
async function submit() {
|
||||
|
|
@ -579,11 +574,8 @@ function TrackSubmission({
|
|||
setSubmitting(true)
|
||||
setFeedback(null)
|
||||
const res = await submitTrack(url.trim())
|
||||
if (res.accepted && res.trackId && res.youtubeId) {
|
||||
setTracks((t) => [
|
||||
...t,
|
||||
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
|
||||
])
|
||||
if (res.accepted) {
|
||||
// La liste se met à jour via l'event serveur blindtest:myTracks.
|
||||
setUrl("")
|
||||
} else {
|
||||
setFeedback(res.reason ?? t.lobby.rejected)
|
||||
|
|
@ -592,10 +584,7 @@ function TrackSubmission({
|
|||
}
|
||||
|
||||
async function remove(trackId: string) {
|
||||
const ok = await removeTrack(trackId)
|
||||
if (ok) {
|
||||
setTracks((t) => t.filter((x) => x.trackId !== trackId))
|
||||
}
|
||||
await removeTrack(trackId)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -604,9 +593,9 @@ function TrackSubmission({
|
|||
{t.lobby.myTracks(myCount, tracksPerPlayer)}
|
||||
</span>
|
||||
|
||||
{tracks.length > 0 && (
|
||||
{myTracks.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tracks.map((track) => (
|
||||
{myTracks.map((track) => (
|
||||
<li
|
||||
key={track.trackId}
|
||||
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
|
||||
|
|
|
|||
|
|
@ -92,7 +92,12 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
)}
|
||||
|
||||
{isText ? (
|
||||
<FreeAnswer disabled={showReveal || hasVoted} onSubmit={voteText} t={t} />
|
||||
<FreeAnswer
|
||||
key={round.endsAt}
|
||||
disabled={showReveal || hasVoted}
|
||||
onSubmit={voteText}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{(question.choices ?? []).map((choice, index) => (
|
||||
|
|
|
|||
31
apps/web/src/components/theme-toggle.tsx
Normal file
31
apps/web/src/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "light", Icon: Sun },
|
||||
{ value: "dark", Icon: Moon },
|
||||
{ value: "system", Icon: Monitor },
|
||||
] as const
|
||||
|
||||
/** Sélecteur de thème : clair / sombre / système. */
|
||||
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div className={`flex gap-1 ${className}`}>
|
||||
{OPTIONS.map(({ value, Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setTheme(value)}
|
||||
title={value}
|
||||
className={`rounded-md p-1.5 transition-colors ${
|
||||
theme === value
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -109,6 +109,8 @@ export const en: Dict = {
|
|||
validate: "Submit",
|
||||
},
|
||||
blindtest: {
|
||||
enableSound: "Enable sound",
|
||||
volume: "Volume",
|
||||
djHint: "You're the DJ 🎧 — control playback (and vote like everyone)",
|
||||
play: "Play",
|
||||
pause: "Pause",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export const fr = {
|
|||
validate: "Valider",
|
||||
},
|
||||
blindtest: {
|
||||
enableSound: "Activer le son",
|
||||
volume: "Volume",
|
||||
djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)",
|
||||
play: "Play",
|
||||
pause: "Pause",
|
||||
|
|
|
|||
45
apps/web/src/lib/clock.ts
Normal file
45
apps/web/src/lib/clock.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Synchro d'horloge avec le serveur : on estime l'offset (horloge serveur −
|
||||
// horloge locale) pour que tous les clients affichent le même décompte, quel que
|
||||
// soit le décalage d'horloge de chaque machine.
|
||||
|
||||
import { socket } from "./socket"
|
||||
|
||||
let offset = 0
|
||||
|
||||
/** Instant courant aligné sur l'horloge serveur. */
|
||||
export function serverNow(): number {
|
||||
return Date.now() + offset
|
||||
}
|
||||
|
||||
/** Une mesure : renvoie [rtt, offset estimé]. */
|
||||
async function syncOnce(): Promise<[number, number]> {
|
||||
const t0 = Date.now()
|
||||
const serverTime = await new Promise<number>((resolve) =>
|
||||
socket.emit("time:sync", resolve)
|
||||
)
|
||||
const t1 = Date.now()
|
||||
const rtt = t1 - t0
|
||||
// Au moment de la réponse (t1), l'horloge serveur valait ~ serverTime + rtt/2.
|
||||
return [rtt, serverTime + rtt / 2 - t1]
|
||||
}
|
||||
|
||||
/** Plusieurs mesures : on garde celle de plus faible latence (plus fiable). */
|
||||
async function sync(): Promise<void> {
|
||||
let bestRtt = Infinity
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
const [rtt, est] = await syncOnce()
|
||||
if (rtt < bestRtt) {
|
||||
bestRtt = rtt
|
||||
offset = est
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("connect", () => void sync())
|
||||
if (socket.connected) {
|
||||
void sync()
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ export interface YoutubeApi {
|
|||
seek: (seconds: number) => void
|
||||
time: () => number
|
||||
duration: () => number
|
||||
/** Volume 0–100. */
|
||||
setVolume: (v: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,6 +67,10 @@ export function useYoutube(youtubeId: string) {
|
|||
modestbranding: 1,
|
||||
rel: 0,
|
||||
playsinline: 1,
|
||||
// Évite l'avertissement postMessage "target origin does not match"
|
||||
// et fiabilise la transmission des commandes au lecteur.
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
},
|
||||
events: {
|
||||
onReady: () => {
|
||||
|
|
@ -91,6 +97,7 @@ export function useYoutube(youtubeId: string) {
|
|||
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
||||
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
||||
duration: () => playerRef.current?.getDuration() ?? 0,
|
||||
setVolume: (v) => playerRef.current?.setVolume(v),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useRoomStore } from "@/store/room"
|
|||
import { useI18n } from "@/i18n/context"
|
||||
import { errorMessage } from "@/i18n/error"
|
||||
import { LanguageToggle } from "@/i18n/language-toggle"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
|
||||
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"
|
||||
|
|
@ -60,7 +61,10 @@ export function HomePage() {
|
|||
|
||||
return (
|
||||
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
||||
<LanguageToggle className="absolute top-4 right-4 z-10" />
|
||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
{/* Halo animé */}
|
||||
<motion.div
|
||||
aria-hidden
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { RoundTransition } from "@/components/round-transition"
|
|||
import { PseudoScreen } from "@/components/pseudo-screen"
|
||||
import { useI18n } from "@/i18n/context"
|
||||
import { LanguageToggle } from "@/i18n/language-toggle"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
|
||||
export function RoomPage({ code }: { code: string }) {
|
||||
const [, navigate] = useLocation()
|
||||
|
|
@ -100,6 +101,7 @@ export function RoomPage({ code }: { code: string }) {
|
|||
{connected ? t.room.connected : t.room.disconnected}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ThemeToggle />
|
||||
<LanguageToggle />
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -129,7 +131,15 @@ export function RoomPage({ code }: { code: string }) {
|
|||
<PlayerCards
|
||||
snapshot={snapshot}
|
||||
playerId={playerId}
|
||||
showVoteStatus={snapshot.status === "in_round"}
|
||||
// En blindtest "qui l'a ajouté" (et mixte), on cache l'état de
|
||||
// vote : le contributeur ne vote jamais → sinon il est repérable.
|
||||
showVoteStatus={
|
||||
snapshot.status === "in_round" &&
|
||||
!(
|
||||
round?.type === "blindtest" &&
|
||||
snapshot.settings.blindtestMode !== "title_artist"
|
||||
)
|
||||
}
|
||||
votedIds={voteProgress?.voted}
|
||||
/>
|
||||
{round?.type === "blindtest" ? (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type ErrorPayload,
|
||||
type MediaControlPayload,
|
||||
type MediaSyncPayload,
|
||||
type MyTrackInfo,
|
||||
type PlayerScore,
|
||||
type RoomCreatedPayload,
|
||||
type RoomSnapshot,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
type VoteAckPayload,
|
||||
} from "@nerdware/shared"
|
||||
import { socket } from "@/lib/socket"
|
||||
import "@/lib/clock" // démarre la synchro d'horloge avec le serveur
|
||||
import {
|
||||
clearSession,
|
||||
getSession,
|
||||
|
|
@ -51,6 +53,8 @@ interface RoomState {
|
|||
pseudoSet: boolean
|
||||
snapshot: RoomSnapshot | null
|
||||
lastError: ErrorPayload | null
|
||||
/** Mes titres blindtest (renvoyés par le serveur, source de vérité). */
|
||||
myTracks: MyTrackInfo[]
|
||||
|
||||
// État de jeu (poussé par le moteur serveur).
|
||||
round: ActiveRound | null
|
||||
|
|
@ -96,6 +100,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
pseudoSet: !!saved?.name,
|
||||
snapshot: null,
|
||||
lastError: null,
|
||||
myTracks: [],
|
||||
round: null,
|
||||
voteProgress: null,
|
||||
reveal: null,
|
||||
|
|
@ -242,6 +247,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
pseudoSet: false,
|
||||
snapshot: null,
|
||||
lastError: null,
|
||||
myTracks: [],
|
||||
round: null,
|
||||
voteProgress: null,
|
||||
reveal: null,
|
||||
|
|
@ -303,6 +309,9 @@ socket.on("room:state", (snapshot) =>
|
|||
)
|
||||
)
|
||||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||
socket.on("blindtest:myTracks", ({ tracks }) =>
|
||||
useRoomStore.setState({ myTracks: tracks })
|
||||
)
|
||||
|
||||
socket.on("round:start", (payload) =>
|
||||
useRoomStore.setState((s) => {
|
||||
|
|
|
|||
1
apps/web/src/types/youtube.d.ts
vendored
1
apps/web/src/types/youtube.d.ts
vendored
|
|
@ -24,6 +24,7 @@ declare global {
|
|||
getCurrentTime(): number
|
||||
getDuration(): number
|
||||
getPlayerState(): number
|
||||
setVolume(volume: number): void
|
||||
loadVideoById(id: string): void
|
||||
destroy(): void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ services:
|
|||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3001
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-nerdware}:${POSTGRES_PASSWORD:-nerdware}@postgres:5432/${POSTGRES_DB:-nerdware}
|
||||
# Connexion via PG* (mot de passe passé tel quel, pas d'encodage d'URL).
|
||||
PGHOST: postgres
|
||||
PGPORT: 5432
|
||||
PGUSER: ${POSTGRES_USER:-nerdware}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:-nerdware}
|
||||
PGDATABASE: ${POSTGRES_DB:-nerdware}
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||
UPLOADS_DIR: /data/uploads
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "nerdware",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@nerdware/shared",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,17 @@ export interface RemoveTrackPayload {
|
|||
trackId: string
|
||||
}
|
||||
|
||||
/** Un titre soumis par le joueur lui-même (renvoyé en privé, source de vérité). */
|
||||
export interface MyTrackInfo {
|
||||
trackId: string
|
||||
title: string
|
||||
youtubeId: string
|
||||
}
|
||||
|
||||
export interface MyTracksPayload {
|
||||
tracks: MyTrackInfo[]
|
||||
}
|
||||
|
||||
export interface RoundStartPayload {
|
||||
type: RoundType
|
||||
djId?: string
|
||||
|
|
@ -147,6 +158,7 @@ export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorP
|
|||
|
||||
/** CLIENT → SERVER */
|
||||
export interface ClientToServerEvents {
|
||||
"time:sync": (ack: (serverNow: number) => void) => void
|
||||
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
||||
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||
|
|
@ -175,6 +187,7 @@ export interface ServerToClientEvents {
|
|||
"player:left": (payload: PlayerRefPayload) => void
|
||||
"player:rejoined": (payload: PlayerRefPayload) => void
|
||||
"blindtest:submitOk": (payload: SubmitOkPayload) => void
|
||||
"blindtest:myTracks": (payload: MyTracksPayload) => void
|
||||
"round:start": (payload: RoundStartPayload) => void
|
||||
"media:sync": (payload: MediaSyncPayload) => void
|
||||
"round:voteAck": (payload: VoteAckPayload) => void
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue