feature/mixed-settings-restyle #12
10 changed files with 104 additions and 8 deletions
|
|
@ -9,10 +9,23 @@ import type { QuizQuestion } from "../game/modes/quiz/questions"
|
||||||
* Pool de questions mcq/truefalse non encore jouées par cette room,
|
* Pool de questions mcq/truefalse non encore jouées par cette room,
|
||||||
* mélangées et limitées. Tableau vide si pas de DB ou rien à servir.
|
* mélangées et limitées. Tableau vide si pas de DB ou rien à servir.
|
||||||
*/
|
*/
|
||||||
|
/** Catégories existantes (pour le filtre du lobby). */
|
||||||
|
export async function listCategories(): Promise<string[]> {
|
||||||
|
if (!db) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select({ name: quizCategory.name })
|
||||||
|
.from(quizCategory)
|
||||||
|
.orderBy(quizCategory.name)
|
||||||
|
return rows.map((r) => r.name)
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadQuizPool(
|
export async function loadQuizPool(
|
||||||
roomCode: string,
|
roomCode: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
formats: string[]
|
formats: string[],
|
||||||
|
categories: string[] = []
|
||||||
): Promise<QuizQuestion[]> {
|
): Promise<QuizQuestion[]> {
|
||||||
if (!db || formats.length === 0) {
|
if (!db || formats.length === 0) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -39,6 +52,9 @@ export async function loadQuizPool(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(quizQuestion.format, formats),
|
inArray(quizQuestion.format, formats),
|
||||||
|
categories.length > 0
|
||||||
|
? inArray(quizCategory.name, categories)
|
||||||
|
: undefined,
|
||||||
notInArray(quizQuestion.id, alreadyPlayed)
|
notInArray(quizQuestion.id, alreadyPlayed)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export class BlindtestRound implements GameRound {
|
||||||
artist?: string
|
artist?: string
|
||||||
guessedPlayerId?: string
|
guessedPlayerId?: string
|
||||||
}
|
}
|
||||||
let text = ""
|
let text: string
|
||||||
if (mode === "who_added") {
|
if (mode === "who_added") {
|
||||||
text = nameOf(a.guessedPlayerId)
|
text = nameOf(a.guessedPlayerId)
|
||||||
} else if (mode === "title_artist") {
|
} else if (mode === "title_artist") {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ async function loadGroup(
|
||||||
room: ServerRoom,
|
room: ServerRoom,
|
||||||
need: number,
|
need: number,
|
||||||
formats: QuizFormat[],
|
formats: QuizFormat[],
|
||||||
isImage: boolean
|
isImage: boolean,
|
||||||
|
categories: string[]
|
||||||
): Promise<QueueItem[]> {
|
): Promise<QueueItem[]> {
|
||||||
if (need <= 0) {
|
if (need <= 0) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -40,7 +41,7 @@ async function loadGroup(
|
||||||
let items: QueueItem[] = []
|
let items: QueueItem[] = []
|
||||||
if (hasDb) {
|
if (hasDb) {
|
||||||
try {
|
try {
|
||||||
const rows = await loadQuizPool(room.code, need, formats)
|
const rows = await loadQuizPool(room.code, need, formats, categories)
|
||||||
items = rows.map((question) => ({ question, fromDb: true }))
|
items = rows.map((question) => ({ question, fromDb: true }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
||||||
|
|
@ -49,7 +50,11 @@ async function loadGroup(
|
||||||
if (items.length < need && !isImage) {
|
if (items.length < need && !isImage) {
|
||||||
// Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
|
// Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
|
||||||
const bank = shuffle(
|
const bank = shuffle(
|
||||||
QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))
|
QUIZ_QUESTIONS.filter(
|
||||||
|
(q) =>
|
||||||
|
formats.includes(q.format) &&
|
||||||
|
(categories.length === 0 || categories.includes(q.category))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for (const question of bank) {
|
for (const question of bank) {
|
||||||
if (items.length >= need) break
|
if (items.length >= need) break
|
||||||
|
|
@ -65,8 +70,9 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||||
const needImage = quizRounds.filter((r) => r.pool === "image").length
|
const needImage = quizRounds.filter((r) => r.pool === "image").length
|
||||||
const needQuiz = quizRounds.length - needImage
|
const needQuiz = quizRounds.length - needImage
|
||||||
|
|
||||||
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false)
|
const cats = room.settings.categories
|
||||||
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true)
|
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats)
|
||||||
|
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats)
|
||||||
|
|
||||||
// On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool).
|
// On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool).
|
||||||
const queue: QueueItem[] = []
|
const queue: QueueItem[] = []
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ export function registerRoomHandlers(
|
||||||
blindtestMode: payload.blindtestMode,
|
blindtestMode: payload.blindtestMode,
|
||||||
roundDuration: payload.roundDuration,
|
roundDuration: payload.roundDuration,
|
||||||
tracksPerPlayer: payload.tracksPerPlayer,
|
tracksPerPlayer: payload.tracksPerPlayer,
|
||||||
|
categories: payload.categories,
|
||||||
rounds: payload.rounds,
|
rounds: payload.rounds,
|
||||||
}
|
}
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import fastifyStatic from "@fastify/static"
|
||||||
import { env, isDev } from "./env"
|
import { env, isDev } from "./env"
|
||||||
import { createSocketServer } from "./socket"
|
import { createSocketServer } from "./socket"
|
||||||
import { adminRoutes } from "./admin/routes"
|
import { adminRoutes } from "./admin/routes"
|
||||||
|
import { listCategories } from "./db/quiz-repo"
|
||||||
import "./game/modes" // enregistre les épreuves (registerRound)
|
import "./game/modes" // enregistre les épreuves (registerRound)
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -25,6 +26,9 @@ await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" })
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
||||||
|
|
||||||
|
// Catégories de quiz disponibles (public, pour le filtre du lobby).
|
||||||
|
app.get("/api/categories", async () => listCategories())
|
||||||
|
|
||||||
await app.register(adminRoutes, { prefix: "/api/admin" })
|
await app.register(adminRoutes, { prefix: "/api/admin" })
|
||||||
|
|
||||||
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
|
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
|
Filter,
|
||||||
Headphones,
|
Headphones,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
|
|
@ -13,6 +15,7 @@ import {
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { SiYoutube } from "@icons-pack/react-simple-icons"
|
import { SiYoutube } from "@icons-pack/react-simple-icons"
|
||||||
|
import { fetchCategories } from "@/lib/categories"
|
||||||
import type {
|
import type {
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
|
@ -133,8 +136,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const updateSettings = useRoomStore((s) => s.updateSettings)
|
const updateSettings = useRoomStore((s) => s.updateSettings)
|
||||||
const startGame = useRoomStore((s) => s.startGame)
|
const startGame = useRoomStore((s) => s.startGame)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
const { gameType, mixedModes, blindtestMode, tracksPerPlayer } =
|
const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } =
|
||||||
snapshot.settings
|
snapshot.settings
|
||||||
|
const allCategories =
|
||||||
|
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
|
||||||
|
|
||||||
const [quizCount, setQuizCount] = useState(5)
|
const [quizCount, setQuizCount] = useState(5)
|
||||||
const [imageCount, setImageCount] = useState(5)
|
const [imageCount, setImageCount] = useState(5)
|
||||||
|
|
@ -186,6 +191,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
updateSettings({ mixedModes: [...set] })
|
updateSettings({ mixedModes: [...set] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCategory(name: string) {
|
||||||
|
const set = new Set(categories)
|
||||||
|
if (set.has(name)) {
|
||||||
|
set.delete(name)
|
||||||
|
} else {
|
||||||
|
set.add(name)
|
||||||
|
}
|
||||||
|
updateSettings({ categories: [...set] })
|
||||||
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -325,6 +340,40 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Filtre de catégories (quiz / images) */}
|
||||||
|
{(showQuiz || showImage) && allCategories.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Filter className="size-4" /> Catégories
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => updateSettings({ categories: [] })}
|
||||||
|
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
categories.length === 0
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-input hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Toutes
|
||||||
|
</button>
|
||||||
|
{allCategories.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => toggleCategory(c)}
|
||||||
|
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
categories.includes(c)
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-input hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Réglages blindtest */}
|
{/* Réglages blindtest */}
|
||||||
{showBlindtest && (
|
{showBlindtest && (
|
||||||
<div className="flex flex-col gap-3 border-t pt-3">
|
<div className="flex flex-col gap-3 border-t pt-3">
|
||||||
|
|
|
||||||
15
apps/web/src/lib/categories.ts
Normal file
15
apps/web/src/lib/categories.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Liste publique des catégories de quiz (pour le filtre du lobby).
|
||||||
|
|
||||||
|
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
||||||
|
|
||||||
|
export async function fetchCategories(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SERVER_URL}/api/categories`)
|
||||||
|
if (!res.ok) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (await res.json()) as string[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -149,6 +149,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
|
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
|
||||||
roundDuration: partial.roundDuration ?? current.roundDuration,
|
roundDuration: partial.roundDuration ?? current.roundDuration,
|
||||||
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
||||||
|
categories: partial.categories ?? current.categories,
|
||||||
rounds: partial.rounds ?? current.rounds,
|
rounds: partial.rounds ?? current.rounds,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ export interface RoomSettings {
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
/** Nombre de titres que chaque joueur soumet (blindtest). */
|
/** Nombre de titres que chaque joueur soumet (blindtest). */
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
|
/** Catégories de quiz autorisées (vide = toutes). */
|
||||||
|
categories: string[]
|
||||||
/** Séquence d'épreuves de la partie. */
|
/** Séquence d'épreuves de la partie. */
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +57,7 @@ export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
||||||
blindtestMode: "title_artist",
|
blindtestMode: "title_artist",
|
||||||
roundDuration: 60,
|
roundDuration: 60,
|
||||||
tracksPerPlayer: 2,
|
tracksPerPlayer: 2,
|
||||||
|
categories: [],
|
||||||
rounds: [],
|
rounds: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export interface UpdateSettingsPayload {
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
|
categories: string[]
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue