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
15 changed files with 530 additions and 6 deletions
Showing only changes of commit b3efa27287 - Show all commits

View file

@ -0,0 +1,2 @@
ALTER TABLE "quiz_question" ADD COLUMN "lang" text DEFAULT 'fr' NOT NULL;--> statement-breakpoint
ALTER TABLE "quiz_question" ADD COLUMN "active" boolean DEFAULT true NOT NULL;

View file

@ -0,0 +1,302 @@
{
"id": "7df3d3d1-dbf0-445e-a5c1-5ef4872a80de",
"prevId": "6de8c2ee-3634-455b-ba4b-372368fc2e66",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.game_history": {
"name": "game_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"modes": {
"name": "modes",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"results": {
"name": "results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_category": {
"name": "quiz_category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_category_name_unique": {
"name": "quiz_category_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_played": {
"name": "quiz_played",
"schema": "",
"columns": {
"question_id": {
"name": "question_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_played_question_id_quiz_question_id_fk": {
"name": "quiz_played_question_id_quiz_question_id_fk",
"tableFrom": "quiz_played",
"tableTo": "quiz_question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"quiz_played_question_id_room_code_pk": {
"name": "quiz_played_question_id_room_code_pk",
"columns": [
"question_id",
"room_code"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_question": {
"name": "quiz_question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"difficulty": {
"name": "difficulty",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lang": {
"name": "lang",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'fr'"
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"choices": {
"name": "choices",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"correct_index": {
"name": "correct_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"accepted_answers": {
"name": "accepted_answers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"quiz_question_prompt_unique": {
"name": "quiz_question_prompt_unique",
"columns": [
{
"expression": "prompt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"format\" <> 'image_reveal'",
"concurrently": false,
"method": "btree",
"with": {}
},
"quiz_question_image_unique": {
"name": "quiz_question_image_unique",
"columns": [
{
"expression": "image_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"quiz_question\".\"image_url\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"quiz_question_category_id_quiz_category_id_fk": {
"name": "quiz_question_category_id_quiz_category_id_fk",
"tableFrom": "quiz_question",
"tableTo": "quiz_category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -15,6 +15,13 @@
"when": 1781129455626, "when": 1781129455626,
"tag": "0001_fancy_captain_stacy", "tag": "0001_fancy_captain_stacy",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1781185531830,
"tag": "0002_lowly_tenebrous",
"breakpoints": true
} }
] ]
} }

View file

@ -19,6 +19,7 @@ interface CreateBody {
prompt?: string prompt?: string
category?: string category?: string
difficulty?: number difficulty?: number
lang?: string
choices?: string[] choices?: string[]
correctIndex?: number correctIndex?: number
acceptedAnswers?: string[] acceptedAnswers?: string[]
@ -46,7 +47,14 @@ function validate(
return { error: "Difficulté invalide (1 à 3)." } return { error: "Difficulté invalide (1 à 3)." }
} }
const base = { categoryId, format, prompt, difficulty, source: "manual" } const base = {
categoryId,
format,
prompt,
difficulty,
source: "manual",
lang: body.lang === "en" ? "en" : "fr",
}
if (format === "free" || format === "image_reveal") { if (format === "free" || format === "image_reveal") {
const answers = (body.acceptedAnswers ?? []) const answers = (body.acceptedAnswers ?? [])
@ -138,6 +146,8 @@ export async function adminRoutes(app: FastifyInstance) {
correctIndex: quizQuestion.correctIndex, correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers, acceptedAnswers: quizQuestion.acceptedAnswers,
imageUrl: quizQuestion.imageUrl, imageUrl: quizQuestion.imageUrl,
lang: quizQuestion.lang,
active: quizQuestion.active,
category: quizCategory.name, category: quizCategory.name,
}) })
.from(quizQuestion) .from(quizQuestion)
@ -174,6 +184,25 @@ export async function adminRoutes(app: FastifyInstance) {
return reply.code(201).send({ id: inserted[0].id }) return reply.code(201).send({ id: inserted[0].id })
}) })
app.patch<{ Params: { id: string }; Body: { active?: boolean } }>(
"/questions/:id",
async (req, reply) => {
const active = req.body?.active
if (typeof active !== "boolean") {
return reply.code(400).send({ error: "Champ 'active' requis." })
}
const updated = await db!
.update(quizQuestion)
.set({ active })
.where(eq(quizQuestion.id, req.params.id))
.returning({ id: quizQuestion.id })
if (updated.length === 0) {
return reply.code(404).send({ error: "Question introuvable." })
}
return { ok: true }
}
)
app.delete<{ Params: { id: string } }>("/questions/:id", async (req, reply) => { app.delete<{ Params: { id: string } }>("/questions/:id", async (req, reply) => {
const deleted = await db! const deleted = await db!
.delete(quizQuestion) .delete(quizQuestion)

View file

@ -0,0 +1,55 @@
// Historique de parties (table game_history). No-op si pas de DB.
import { desc, eq } from "drizzle-orm"
import { db } from "./index"
import { gameHistory } from "./schema"
export interface GameResultEntry {
name: string
score: number
}
export async function saveGameHistory(
roomCode: string,
modes: string[],
results: GameResultEntry[]
): Promise<void> {
if (!db) {
return
}
await db.insert(gameHistory).values({ roomCode, modes, results })
}
export interface GameHistoryRow {
id: string
playedAt: string
modes: string[]
results: GameResultEntry[]
}
export async function listGameHistory(
roomCode: string,
limit = 10
): Promise<GameHistoryRow[]> {
if (!db) {
return []
}
const rows = await db
.select({
id: gameHistory.id,
playedAt: gameHistory.playedAt,
modes: gameHistory.modes,
results: gameHistory.results,
})
.from(gameHistory)
.where(eq(gameHistory.roomCode, roomCode))
.orderBy(desc(gameHistory.playedAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
playedAt:
r.playedAt instanceof Date ? r.playedAt.toISOString() : String(r.playedAt),
modes: (r.modes as string[]) ?? [],
results: (r.results as GameResultEntry[]) ?? [],
}))
}

View file

@ -51,6 +51,7 @@ export async function loadQuizPool(
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where( .where(
and( and(
eq(quizQuestion.active, true),
inArray(quizQuestion.format, formats), inArray(quizQuestion.format, formats),
categories.length > 0 categories.length > 0
? inArray(quizCategory.name, categories) ? inArray(quizCategory.name, categories)

View file

@ -3,6 +3,7 @@
import { sql } from "drizzle-orm" import { sql } from "drizzle-orm"
import { import {
boolean,
integer, integer,
jsonb, jsonb,
pgTable, pgTable,
@ -31,8 +32,12 @@ export const quizQuestion = pgTable(
prompt: text("prompt").notNull(), prompt: text("prompt").notNull(),
/** 1 (facile) .. 3 (difficile). */ /** 1 (facile) .. 3 (difficile). */
difficulty: integer("difficulty").notNull().default(1), difficulty: integer("difficulty").notNull().default(1),
/** 'opentdb' | 'manual' */ /** 'opentdb' | 'manual' | 'pokeapi' */
source: text("source").notNull(), source: text("source").notNull(),
/** Langue de la question ('fr' | 'en'). */
lang: text("lang").notNull().default("fr"),
/** Désactivée (back-office) : exclue des parties sans être supprimée. */
active: boolean("active").notNull().default(true),
// Champs selon le format (NULL si non applicable) : // Champs selon le format (NULL si non applicable) :
choices: jsonb("choices").$type<string[]>(), choices: jsonb("choices").$type<string[]>(),
correctIndex: integer("correct_index"), correctIndex: integer("correct_index"),

View file

@ -64,6 +64,7 @@ async function fetchPokemon(id: number): Promise<NewQuizQuestion | null> {
prompt: PROMPT, prompt: PROMPT,
difficulty: 2, difficulty: 2,
source: "pokeapi", source: "pokeapi",
lang: "fr",
acceptedAnswers: answers, acceptedAnswers: answers,
imageUrl: art, imageUrl: art,
} }

View file

@ -113,6 +113,7 @@ function toQuestion(
prompt, prompt,
difficulty, difficulty,
source: "opentdb", source: "opentdb",
lang: "en",
choices: ["True", "False"], choices: ["True", "False"],
correctIndex: correct === "True" ? 0 : 1, correctIndex: correct === "True" ? 0 : 1,
} }
@ -128,6 +129,7 @@ function toQuestion(
prompt, prompt,
difficulty, difficulty,
source: "opentdb", source: "opentdb",
lang: "en",
choices, choices,
correctIndex: choices.indexOf(correct), correctIndex: choices.indexOf(correct),
} }
@ -177,6 +179,7 @@ async function seedManual(): Promise<number> {
prompt: q.prompt, prompt: q.prompt,
difficulty: q.difficulty, difficulty: q.difficulty,
source: "manual", source: "manual",
lang: "fr",
choices: q.choices, choices: q.choices,
correctIndex: q.correctIndex, correctIndex: q.correctIndex,
acceptedAnswers: q.acceptedAnswers, acceptedAnswers: q.acceptedAnswers,

View file

@ -11,6 +11,7 @@ import type {
} from "@nerdware/shared" } from "@nerdware/shared"
import type { IoServer } from "../socket" import type { IoServer } from "../socket"
import type { RoomManager, ServerRoom } from "../rooms" import type { RoomManager, ServerRoom } from "../rooms"
import { saveGameHistory } from "../db/history-repo"
import { createRound as defaultCreateRound, type RoundFactory } from "./registry" import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
import type { GameRound, RoomGameController, RoundContext } from "./round" import type { GameRound, RoomGameController, RoundContext } from "./round"
@ -87,6 +88,18 @@ export class GameEngine implements RoomGameController {
tracks: tracks.length > 0 ? tracks : undefined, tracks: tracks.length > 0 ? tracks : undefined,
recap: this.history.length > 0 ? this.history : undefined, recap: this.history.length > 0 ? this.history : undefined,
}) })
// Historique (fire-and-forget, DB optionnelle).
const modes = [...new Set(this.room.settings.rounds.map((r) => r.type))]
const results = this.scoreboard()
.map((s) => ({
name: this.room.players.get(s.playerId)?.name ?? "?",
score: s.score,
}))
.sort((a, b) => b.score - a.score)
void saveGameHistory(this.room.code, modes, results).catch((err) =>
console.error("[history] save failed", err)
)
} }
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */ /** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */

View file

@ -8,6 +8,7 @@ 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 { listCategories } from "./db/quiz-repo"
import { listGameHistory } from "./db/history-repo"
import "./game/modes" // enregistre les épreuves (registerRound) import "./game/modes" // enregistre les épreuves (registerRound)
const app = Fastify({ const app = Fastify({
@ -29,6 +30,11 @@ app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
// Catégories de quiz disponibles (public, pour le filtre du lobby). // Catégories de quiz disponibles (public, pour le filtre du lobby).
app.get("/api/categories", async () => listCategories()) app.get("/api/categories", async () => listCategories())
// Historique des parties d'une room (public).
app.get<{ Params: { code: string } }>("/api/history/:code", async (req) =>
listGameHistory(req.params.code.toUpperCase())
)
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.

View file

@ -16,6 +16,7 @@ import {
} 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 { fetchCategories } from "@/lib/categories"
import { fetchHistory } from "@/lib/history"
import type { import type {
BlindtestMode, BlindtestMode,
GameType, GameType,
@ -140,6 +141,11 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
snapshot.settings snapshot.settings
const allCategories = const allCategories =
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? [] useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
const history =
useQuery({
queryKey: ["history", snapshot.code],
queryFn: () => fetchHistory(snapshot.code),
}).data ?? []
const [quizCount, setQuizCount] = useState(5) const [quizCount, setQuizCount] = useState(5)
const [imageCount, setImageCount] = useState(5) const [imageCount, setImageCount] = useState(5)
@ -433,6 +439,38 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
)} )}
{error && <p className="text-destructive text-center text-sm">{error}</p>} {error && <p className="text-destructive text-center text-sm">{error}</p>}
{history.length > 0 && (
<details className="rounded-xl border text-left">
<summary className="text-muted-foreground cursor-pointer p-3 text-sm font-medium select-none">
Parties précédentes ({history.length})
</summary>
<ul className="flex flex-col gap-2 p-3 pt-0">
{history.map((g) => {
const top = [...g.results].sort((a, b) => b.score - a.score)
return (
<li key={g.id} className="bg-muted/40 rounded-lg p-2 text-xs">
<p className="text-muted-foreground text-[10px] uppercase">
{new Date(g.playedAt).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
})}
{" · "}
{g.modes.join(", ")}
</p>
<p>
🏆 {top[0]?.name ?? "?"}
<span className="text-muted-foreground">
{" "}
{top.map((r) => `${r.name} ${r.score}`).join(" · ")}
</span>
</p>
</li>
)
})}
</ul>
</details>
)}
</div> </div>
</div> </div>
) )

View file

@ -25,6 +25,8 @@ export interface AdminQuestion {
correctIndex: number | null correctIndex: number | null
acceptedAnswers: string[] | null acceptedAnswers: string[] | null
imageUrl: string | null imageUrl: string | null
lang: string
active: boolean
category: string | null category: string | null
} }
@ -33,6 +35,7 @@ export interface NewQuestionInput {
prompt: string prompt: string
category: string category: string
difficulty: number difficulty: number
lang?: string
choices?: string[] choices?: string[]
correctIndex?: number correctIndex?: number
acceptedAnswers?: string[] acceptedAnswers?: string[]
@ -83,6 +86,11 @@ export const adminApi = {
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}), }),
setActive: (id: string, active: boolean) =>
request<{ ok: true }>(`/questions/${id}`, {
method: "PATCH",
body: JSON.stringify({ active }),
}),
deleteQuestion: (id: string) => deleteQuestion: (id: string) =>
request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }), request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }),
} }

View file

@ -0,0 +1,22 @@
// Historique public des parties d'une room.
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
export interface GameHistoryEntry {
id: string
playedAt: string
modes: string[]
results: { name: string; score: number }[]
}
export async function fetchHistory(code: string): Promise<GameHistoryEntry[]> {
try {
const res = await fetch(`${SERVER_URL}/api/history/${code}`)
if (!res.ok) {
return []
}
return (await res.json()) as GameHistoryEntry[]
} catch {
return []
}
}

View file

@ -1,7 +1,7 @@
import { useRef, useState } from "react" import { useRef, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "wouter" import { Link } from "wouter"
import { Trash2, Upload } from "lucide-react" import { Eye, EyeOff, Trash2, Upload } from "lucide-react"
import type { QuizFormat } from "@nerdware/shared" import type { QuizFormat } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { import {
@ -86,6 +86,12 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
}) })
const toggle = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
adminApi.setActive(id, active),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
})
const unauthorized = const unauthorized =
questions.isError && /401|autoris/i.test(String(questions.error)) questions.isError && /401|autoris/i.test(String(questions.error))
@ -126,6 +132,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
key={q.id} key={q.id}
q={q} q={q}
onDelete={() => remove.mutate(q.id)} onDelete={() => remove.mutate(q.id)}
onToggle={() => toggle.mutate({ id: q.id, active: !q.active })}
deleting={remove.isPending} deleting={remove.isPending}
/> />
))} ))}
@ -140,14 +147,20 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
function QuestionRow({ function QuestionRow({
q, q,
onDelete, onDelete,
onToggle,
deleting, deleting,
}: { }: {
q: AdminQuestion q: AdminQuestion
onDelete: () => void onDelete: () => void
onToggle: () => void
deleting: boolean deleting: boolean
}) { }) {
return ( return (
<li className="bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3"> <li
className={`bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3 ${
q.active ? "" : "opacity-50"
}`}
>
{q.format === "image_reveal" && q.imageUrl && ( {q.format === "image_reveal" && q.imageUrl && (
<img <img
src={assetUrl(q.imageUrl)} src={assetUrl(q.imageUrl)}
@ -158,7 +171,9 @@ function QuestionRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">{q.prompt}</p> <p className="text-sm font-medium">{q.prompt}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source} {q.format} · {q.category ?? "—"} · diff. {q.difficulty} ·{" "}
{q.lang.toUpperCase()} · {q.source}
{!q.active && " · désactivée"}
</p> </p>
{q.format === "free" || q.format === "image_reveal" ? ( {q.format === "free" || q.format === "image_reveal" ? (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@ -172,6 +187,14 @@ function QuestionRow({
</p> </p>
)} )}
</div> </div>
<Button
size="icon-sm"
variant="secondary"
onClick={onToggle}
title={q.active ? "Désactiver" : "Activer"}
>
{q.active ? <Eye /> : <EyeOff />}
</Button>
<Button <Button
size="icon-sm" size="icon-sm"
variant="secondary" variant="secondary"
@ -191,6 +214,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
const [prompt, setPrompt] = useState("") const [prompt, setPrompt] = useState("")
const [category, setCategory] = useState("") const [category, setCategory] = useState("")
const [difficulty, setDifficulty] = useState(1) const [difficulty, setDifficulty] = useState(1)
const [lang, setLang] = useState("fr")
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES) const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
const [correctIndex, setCorrectIndex] = useState(0) const [correctIndex, setCorrectIndex] = useState(0)
const [answers, setAnswers] = useState("") const [answers, setAnswers] = useState("")
@ -228,7 +252,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
} }
function submit() { function submit() {
const input: NewQuestionInput = { format, prompt, category, difficulty } const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
if (format === "free") { if (format === "free") {
input.acceptedAnswers = answers input.acceptedAnswers = answers
.split("\n") .split("\n")
@ -277,6 +301,14 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
<option value={2}>Moyen</option> <option value={2}>Moyen</option>
<option value={3}>Difficile</option> <option value={3}>Difficile</option>
</select> </select>
<select
className={inputClass}
value={lang}
onChange={(e) => setLang(e.target.value)}
>
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
</div> </div>
<input <input