Merge pull request 'release/0.1.4' (#26) from release/0.1.4 into main
Reviewed-on: #26
This commit is contained in:
commit
9f8b39435e
18 changed files with 204 additions and 33 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -4,6 +4,15 @@ Toutes les évolutions notables de NerdWare. Format basé sur
|
||||||
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
|
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
|
||||||
[SemVer](https://semver.org/lang/fr/).
|
[SemVer](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [0.1.4] — 2026-06-12
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Mode Rébus** : devine l'œuvre geek à partir d'un rébus emoji (réponse libre,
|
||||||
|
matching tolérant). Implémenté comme format de quiz `rebus` (réutilise pool,
|
||||||
|
anti-doublons, scoring, récap, bots, back-office). Tuile dédiée au lobby,
|
||||||
|
affichage emoji en grand, transition 🧩, 9 rébus fournis + création au back-office.
|
||||||
|
|
||||||
## [0.1.3] — 2026-06-12
|
## [0.1.3] — 2026-06-12
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
@ -71,6 +80,7 @@ fin), trois modes, back-office et internationalisation.
|
||||||
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
|
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
|
||||||
prêt pour Dokploy.
|
prêt pour Dokploy.
|
||||||
|
|
||||||
|
[0.1.4]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.4
|
||||||
[0.1.3]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.3
|
[0.1.3]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.3
|
||||||
[0.1.2]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.2
|
[0.1.2]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.2
|
||||||
[0.1.1]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.1
|
[0.1.1]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.1
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nerdware/server",
|
"name": "@nerdware/server",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { env } from "../env"
|
||||||
import { db } from "../db"
|
import { db } from "../db"
|
||||||
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
|
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
|
||||||
|
|
||||||
const FORMATS = ["mcq", "truefalse", "free", "image_reveal"] as const
|
const FORMATS = ["mcq", "truefalse", "free", "image_reveal", "rebus"] as const
|
||||||
type AdminFormat = (typeof FORMATS)[number]
|
type AdminFormat = (typeof FORMATS)[number]
|
||||||
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
|
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ function validate(
|
||||||
lang: body.lang === "en" ? "en" : "fr",
|
lang: body.lang === "en" ? "en" : "fr",
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "free" || format === "image_reveal") {
|
if (format === "free" || format === "image_reveal" || format === "rebus") {
|
||||||
const answers = (body.acceptedAnswers ?? [])
|
const answers = (body.acceptedAnswers ?? [])
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const poolByRoom = new WeakMap<ServerRoom, QueueItem[]>()
|
||||||
|
|
||||||
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
|
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
|
||||||
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
|
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
|
||||||
|
const REBUS_FORMATS: QuizFormat[] = ["rebus"]
|
||||||
|
|
||||||
function shuffle<T>(items: T[]): T[] {
|
function shuffle<T>(items: T[]): T[] {
|
||||||
const arr = [...items]
|
const arr = [...items]
|
||||||
|
|
@ -32,7 +33,7 @@ async function loadGroup(
|
||||||
room: ServerRoom,
|
room: ServerRoom,
|
||||||
need: number,
|
need: number,
|
||||||
formats: QuizFormat[],
|
formats: QuizFormat[],
|
||||||
isImage: boolean,
|
allowHardcoded: boolean,
|
||||||
categories: string[]
|
categories: string[]
|
||||||
): Promise<QueueItem[]> {
|
): Promise<QueueItem[]> {
|
||||||
if (need <= 0) {
|
if (need <= 0) {
|
||||||
|
|
@ -47,8 +48,8 @@ async function loadGroup(
|
||||||
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (items.length < need && !isImage) {
|
if (items.length < need && allowHardcoded) {
|
||||||
// Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
|
// Complément depuis la banque en dur (quiz + rébus ; pas d'images en dur).
|
||||||
const bank = shuffle(
|
const bank = shuffle(
|
||||||
QUIZ_QUESTIONS.filter(
|
QUIZ_QUESTIONS.filter(
|
||||||
(q) =>
|
(q) =>
|
||||||
|
|
@ -68,16 +69,24 @@ async function loadGroup(
|
||||||
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||||
const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz")
|
const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz")
|
||||||
const needImage = quizRounds.filter((r) => r.pool === "image").length
|
const needImage = quizRounds.filter((r) => r.pool === "image").length
|
||||||
const needQuiz = quizRounds.length - needImage
|
const needRebus = quizRounds.filter((r) => r.pool === "rebus").length
|
||||||
|
const needQuiz = quizRounds.length - needImage - needRebus
|
||||||
|
|
||||||
const cats = room.settings.categories
|
const cats = room.settings.categories
|
||||||
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats)
|
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, true, cats)
|
||||||
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats)
|
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, false, cats)
|
||||||
|
// Les rébus ne sont pas filtrés par catégorie (catégorie « Rébus » à part).
|
||||||
|
const rebusGroup = await loadGroup(room, needRebus, REBUS_FORMATS, true, [])
|
||||||
|
|
||||||
// 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[] = []
|
||||||
for (const r of quizRounds) {
|
for (const r of quizRounds) {
|
||||||
const group = r.pool === "image" ? imageGroup : quizGroup
|
const group =
|
||||||
|
r.pool === "image"
|
||||||
|
? imageGroup
|
||||||
|
: r.pool === "rebus"
|
||||||
|
? rebusGroup
|
||||||
|
: quizGroup
|
||||||
const item = group.shift()
|
const item = group.shift()
|
||||||
if (item) {
|
if (item) {
|
||||||
queue.push(item)
|
queue.push(item)
|
||||||
|
|
|
||||||
|
|
@ -154,4 +154,82 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [
|
||||||
category: "Manga",
|
category: "Manga",
|
||||||
difficulty: 3,
|
difficulty: 3,
|
||||||
},
|
},
|
||||||
|
// Rébus : devine l'œuvre à partir des emojis (réponse libre, matching tolérant).
|
||||||
|
{
|
||||||
|
id: "r-sonic",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🦔 + 💨 + 💍",
|
||||||
|
acceptedAnswers: ["Sonic", "Sonic the Hedgehog"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-mario",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🍄 + 🔨 + 🐢",
|
||||||
|
acceptedAnswers: ["Mario", "Super Mario", "Super Mario Bros"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-pacman",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🟡 + 👻 + 🕹️",
|
||||||
|
acceptedAnswers: ["Pac-Man", "Pacman", "Pac Man"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-pokemon",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⚡ + 🐭 + 🔴⚪",
|
||||||
|
acceptedAnswers: ["Pokémon", "Pokemon"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-harry-potter",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⚡ + 🧙♂️ + 🎓",
|
||||||
|
acceptedAnswers: ["Harry Potter", "Harry"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-lotr",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "💍 + 🌋 + 🧙♂️",
|
||||||
|
acceptedAnswers: [
|
||||||
|
"Le Seigneur des Anneaux",
|
||||||
|
"Seigneur des Anneaux",
|
||||||
|
"Lord of the Rings",
|
||||||
|
"LOTR",
|
||||||
|
],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-star-wars",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⭐ + ⚔️ + 🌌",
|
||||||
|
acceptedAnswers: ["Star Wars", "La Guerre des Étoiles"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-minecraft",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "⛏️ + 🟩 + 🧟",
|
||||||
|
acceptedAnswers: ["Minecraft"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-zelda",
|
||||||
|
format: "rebus",
|
||||||
|
prompt: "🗡️ + 🛡️ + 🧝♂️ + 🐴",
|
||||||
|
acceptedAnswers: ["Zelda", "The Legend of Zelda", "Legend of Zelda"],
|
||||||
|
category: "Rébus",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ function asText(answer: Answer): string | null {
|
||||||
|
|
||||||
/** Formats à réponse libre (saisie texte + matching tolérant). */
|
/** Formats à réponse libre (saisie texte + matching tolérant). */
|
||||||
function isTextFormat(question: QuizQuestion): boolean {
|
function isTextFormat(question: QuizQuestion): boolean {
|
||||||
return question.format === "free" || question.format === "image_reveal"
|
return (
|
||||||
|
question.format === "free" ||
|
||||||
|
question.format === "image_reveal" ||
|
||||||
|
question.format === "rebus"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Réponse correcte ? Selon le format de la question. */
|
/** Réponse correcte ? Selon le format de la question. */
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Music,
|
Music,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Puzzle,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
Trash2,
|
Trash2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
|
|
@ -43,6 +44,7 @@ const GAME_TYPES: {
|
||||||
{ value: "mixed", Icon: Shuffle, needsThree: false },
|
{ value: "mixed", Icon: Shuffle, needsThree: false },
|
||||||
{ value: "quiz", Icon: Brain, needsThree: false },
|
{ value: "quiz", Icon: Brain, needsThree: false },
|
||||||
{ value: "image", Icon: ImageIcon, needsThree: false },
|
{ value: "image", Icon: ImageIcon, needsThree: false },
|
||||||
|
{ value: "rebus", Icon: Puzzle, needsThree: false },
|
||||||
{ value: "blindtest", Icon: Headphones, needsThree: true },
|
{ value: "blindtest", Icon: Headphones, needsThree: true },
|
||||||
]
|
]
|
||||||
const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const
|
const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const
|
||||||
|
|
@ -110,6 +112,7 @@ function shuffle<T>(items: T[]): T[] {
|
||||||
function buildRounds(opts: {
|
function buildRounds(opts: {
|
||||||
quiz: number
|
quiz: number
|
||||||
image: number
|
image: number
|
||||||
|
rebus: number
|
||||||
tracks: number
|
tracks: number
|
||||||
shuffleOrder: boolean
|
shuffleOrder: boolean
|
||||||
}): RoundConfig[] {
|
}): RoundConfig[] {
|
||||||
|
|
@ -122,6 +125,10 @@ function buildRounds(opts: {
|
||||||
type: "quiz" as const,
|
type: "quiz" as const,
|
||||||
pool: "image" as const,
|
pool: "image" as const,
|
||||||
})),
|
})),
|
||||||
|
...Array.from({ length: opts.rebus }, () => ({
|
||||||
|
type: "quiz" as const,
|
||||||
|
pool: "rebus" as const,
|
||||||
|
})),
|
||||||
...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })),
|
...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })),
|
||||||
]
|
]
|
||||||
return opts.shuffleOrder ? shuffle(rounds) : rounds
|
return opts.shuffleOrder ? shuffle(rounds) : rounds
|
||||||
|
|
@ -154,6 +161,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
||||||
const [quizCount, setQuizCount] = useState(5)
|
const [quizCount, setQuizCount] = useState(5)
|
||||||
const [imageCount, setImageCount] = useState(5)
|
const [imageCount, setImageCount] = useState(5)
|
||||||
|
const [rebusCount, setRebusCount] = useState(5)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -179,6 +187,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
|
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
|
||||||
const showImage =
|
const showImage =
|
||||||
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
|
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
|
||||||
|
const showRebus =
|
||||||
|
effectiveType === "rebus" || (isMixed && mixedModes.includes("rebus"))
|
||||||
const showBlindtest =
|
const showBlindtest =
|
||||||
effectiveType === "blindtest" ||
|
effectiveType === "blindtest" ||
|
||||||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
|
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
|
||||||
|
|
@ -186,6 +196,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const rounds = buildRounds({
|
const rounds = buildRounds({
|
||||||
quiz: showQuiz ? quizCount : 0,
|
quiz: showQuiz ? quizCount : 0,
|
||||||
image: showImage ? imageCount : 0,
|
image: showImage ? imageCount : 0,
|
||||||
|
rebus: showRebus ? rebusCount : 0,
|
||||||
tracks: showBlindtest ? totalTracks : 0,
|
tracks: showBlindtest ? totalTracks : 0,
|
||||||
shuffleOrder: isMixed,
|
shuffleOrder: isMixed,
|
||||||
})
|
})
|
||||||
|
|
@ -295,7 +306,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{/* Mode de jeu */}
|
{/* Mode de jeu */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">{t.lobby.gameMode}</span>
|
<span className="text-sm font-medium">{t.lobby.gameMode}</span>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{GAME_TYPES.map(({ value, Icon, needsThree }) => {
|
{GAME_TYPES.map(({ value, Icon, needsThree }) => {
|
||||||
const locked = needsThree && !blindtestAvailable
|
const locked = needsThree && !blindtestAvailable
|
||||||
const active = effectiveType === value
|
const active = effectiveType === value
|
||||||
|
|
@ -339,7 +350,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">{t.lobby.subModes}</span>
|
<span className="text-sm font-medium">{t.lobby.subModes}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(["quiz", "image", "blindtest"] as const).map((m) => {
|
{(["quiz", "image", "rebus", "blindtest"] as const).map((m) => {
|
||||||
const locked = m === "blindtest" && !blindtestAvailable
|
const locked = m === "blindtest" && !blindtestAvailable
|
||||||
const on = mixedModes.includes(m) && !locked
|
const on = mixedModes.includes(m) && !locked
|
||||||
const tile = (
|
const tile = (
|
||||||
|
|
@ -400,6 +411,21 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Nombre de rébus */}
|
||||||
|
{showRebus && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Puzzle className="size-4" /> {t.lobby.rebusCount}
|
||||||
|
</span>
|
||||||
|
<Stepper
|
||||||
|
value={rebusCount}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
onChange={setRebusCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filtre de catégories (quiz / images) */}
|
{/* Filtre de catégories (quiz / images) */}
|
||||||
{(showQuiz || showImage) && allCategories.length > 0 && (
|
{(showQuiz || showImage) && allCategories.length > 0 && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||||
const showReveal = truth !== null
|
const showReveal = truth !== null
|
||||||
const isImage = question.format === "image_reveal"
|
const isImage = question.format === "image_reveal"
|
||||||
const isText = question.format === "free" || isImage
|
const isRebus = question.format === "rebus"
|
||||||
|
const isText = question.format === "free" || isImage || isRebus
|
||||||
const myResult = reveal
|
const myResult = reveal
|
||||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -74,9 +75,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<h2 className="font-heading text-xl font-semibold text-balance">
|
{isRebus ? (
|
||||||
{question.prompt}
|
<div className="bg-card flex min-h-32 items-center justify-center rounded-2xl p-6 text-center text-4xl leading-relaxed text-balance sm:text-5xl">
|
||||||
</h2>
|
{question.prompt}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h2 className="font-heading text-xl font-semibold text-balance">
|
||||||
|
{question.prompt}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
{isImage && question.imageUrl && (
|
{isImage && question.imageUrl && (
|
||||||
<ImageReveal
|
<ImageReveal
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "framer-motion"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
|
|
||||||
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
|
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
|
||||||
export type TransitionKind = "quiz" | "image" | "blindtest"
|
export type TransitionKind = "quiz" | "image" | "rebus" | "blindtest"
|
||||||
|
|
||||||
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
|
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
|
||||||
|
|
||||||
|
|
@ -19,11 +19,17 @@ const ALL_MEDIA = import.meta.glob(
|
||||||
{ eager: true, import: "default" }
|
{ eager: true, import: "default" }
|
||||||
) as Record<string, string>
|
) as Record<string, string>
|
||||||
|
|
||||||
const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
|
const MEDIA: Record<TransitionKind, string[]> = {
|
||||||
|
quiz: [],
|
||||||
|
image: [],
|
||||||
|
rebus: [],
|
||||||
|
blindtest: [],
|
||||||
|
}
|
||||||
const SHARED_MEDIA: string[] = []
|
const SHARED_MEDIA: string[] = []
|
||||||
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
||||||
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
|
if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
|
||||||
else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
|
else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
|
||||||
|
else if (path.includes("/transitions/rebus/")) MEDIA.rebus.push(url)
|
||||||
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
|
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
|
||||||
else SHARED_MEDIA.push(url)
|
else SHARED_MEDIA.push(url)
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +52,13 @@ const THEME: Record<
|
||||||
kind: "Image",
|
kind: "Image",
|
||||||
emoji: "🖼️",
|
emoji: "🖼️",
|
||||||
},
|
},
|
||||||
|
rebus: {
|
||||||
|
gradient: "from-amber-500 via-orange-600 to-rose-600",
|
||||||
|
accent: "text-yellow-200",
|
||||||
|
label: "RÉBUS",
|
||||||
|
kind: "Rébus",
|
||||||
|
emoji: "🧩",
|
||||||
|
},
|
||||||
blindtest: {
|
blindtest: {
|
||||||
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
||||||
accent: "text-yellow-300",
|
accent: "text-yellow-300",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export const en: Dict = {
|
||||||
quizCount: "Quiz questions",
|
quizCount: "Quiz questions",
|
||||||
imageCount: "Images to guess",
|
imageCount: "Images to guess",
|
||||||
imageHint: "Requires “Image” questions created in the back-office.",
|
imageHint: "Requires “Image” questions created in the back-office.",
|
||||||
|
rebusCount: "Rebuses to guess",
|
||||||
categories: "Categories",
|
categories: "Categories",
|
||||||
allCategories: "All",
|
allCategories: "All",
|
||||||
botLevel: "🤖 Bot difficulty",
|
botLevel: "🤖 Bot difficulty",
|
||||||
|
|
@ -83,11 +84,13 @@ export const en: Dict = {
|
||||||
mixed: "Mixed",
|
mixed: "Mixed",
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rebus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
mixModes: {
|
mixModes: {
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rebus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
blindtestModes: {
|
blindtestModes: {
|
||||||
|
|
@ -142,6 +145,7 @@ export const en: Dict = {
|
||||||
kind: {
|
kind: {
|
||||||
quiz: "Question",
|
quiz: "Question",
|
||||||
image: "Image",
|
image: "Image",
|
||||||
|
rebus: "Rebus",
|
||||||
blindtest: "Track",
|
blindtest: "Track",
|
||||||
},
|
},
|
||||||
ready: "READY ?!",
|
ready: "READY ?!",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export const fr = {
|
||||||
quizCount: "Questions de quiz",
|
quizCount: "Questions de quiz",
|
||||||
imageCount: "Images à deviner",
|
imageCount: "Images à deviner",
|
||||||
imageHint: "Nécessite des questions « Image » créées dans le back-office.",
|
imageHint: "Nécessite des questions « Image » créées dans le back-office.",
|
||||||
|
rebusCount: "Rébus à deviner",
|
||||||
categories: "Catégories",
|
categories: "Catégories",
|
||||||
allCategories: "Toutes",
|
allCategories: "Toutes",
|
||||||
botLevel: "🤖 Niveau des bots",
|
botLevel: "🤖 Niveau des bots",
|
||||||
|
|
@ -81,11 +82,13 @@ export const fr = {
|
||||||
mixed: "Mixte",
|
mixed: "Mixte",
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rébus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
mixModes: {
|
mixModes: {
|
||||||
quiz: "Quiz",
|
quiz: "Quiz",
|
||||||
image: "Images",
|
image: "Images",
|
||||||
|
rebus: "Rébus",
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
},
|
},
|
||||||
blindtestModes: {
|
blindtestModes: {
|
||||||
|
|
@ -141,6 +144,7 @@ export const fr = {
|
||||||
kind: {
|
kind: {
|
||||||
quiz: "Question",
|
quiz: "Question",
|
||||||
image: "Image",
|
image: "Image",
|
||||||
|
rebus: "Rébus",
|
||||||
blindtest: "Titre",
|
blindtest: "Titre",
|
||||||
},
|
},
|
||||||
ready: "PRÊT ?!",
|
ready: "PRÊT ?!",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ export interface PatchNote {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PATCH_NOTES: PatchNote[] = [
|
export const PATCH_NOTES: PatchNote[] = [
|
||||||
|
{
|
||||||
|
version: "0.1.4",
|
||||||
|
date: "2026-06-12",
|
||||||
|
fr: ["Nouveau mode Rébus 🧩 : devine l'œuvre à partir des emojis !"],
|
||||||
|
en: ["New Rebus mode 🧩: guess the work from emojis!"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.1.2",
|
version: "0.1.2",
|
||||||
date: "2026-06-12",
|
date: "2026-06-12",
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,9 @@ function QuestionRow({
|
||||||
{q.lang.toUpperCase()} · {q.source}
|
{q.lang.toUpperCase()} · {q.source}
|
||||||
{!q.active && " · désactivée"}
|
{!q.active && " · désactivée"}
|
||||||
</p>
|
</p>
|
||||||
{q.format === "free" || q.format === "image_reveal" ? (
|
{q.format === "free" ||
|
||||||
|
q.format === "image_reveal" ||
|
||||||
|
q.format === "rebus" ? (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
✔ {q.acceptedAnswers?.join(", ")}
|
✔ {q.acceptedAnswers?.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -258,7 +260,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
|
const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
|
||||||
if (format === "free") {
|
if (format === "free" || format === "rebus") {
|
||||||
input.acceptedAnswers = answers
|
input.acceptedAnswers = answers
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
|
|
@ -296,6 +298,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
<option value="truefalse">Vrai / Faux</option>
|
<option value="truefalse">Vrai / Faux</option>
|
||||||
<option value="free">Réponse libre</option>
|
<option value="free">Réponse libre</option>
|
||||||
<option value="image_reveal">Image à deviner</option>
|
<option value="image_reveal">Image à deviner</option>
|
||||||
|
<option value="rebus">Rébus</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
|
@ -412,7 +415,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(format === "free" || format === "image_reveal") && (
|
{(format === "free" || format === "image_reveal" || format === "rebus") && (
|
||||||
<textarea
|
<textarea
|
||||||
className={`${inputClass} h-24 py-2`}
|
className={`${inputClass} h-24 py-2`}
|
||||||
placeholder="Réponses acceptées (une par ligne)"
|
placeholder="Réponses acceptées (une par ligne)"
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ interface RoomState {
|
||||||
mediaSync: MediaSyncPayload | null
|
mediaSync: MediaSyncPayload | null
|
||||||
boomKey: number
|
boomKey: number
|
||||||
roundKey: number
|
roundKey: number
|
||||||
/** Catégorie visuelle de la manche (image_reveal distinct du quiz). */
|
/** Catégorie visuelle de la manche (image_reveal / rébus distincts du quiz). */
|
||||||
roundKind: "quiz" | "image" | "blindtest"
|
roundKind: "quiz" | "image" | "rebus" | "blindtest"
|
||||||
/** Le mode a-t-il changé par rapport à la manche précédente ? */
|
/** Le mode a-t-il changé par rapport à la manche précédente ? */
|
||||||
roundModeChanged: boolean
|
roundModeChanged: boolean
|
||||||
|
|
||||||
|
|
@ -321,7 +321,9 @@ socket.on("round:start", (payload) =>
|
||||||
? "blindtest"
|
? "blindtest"
|
||||||
: fmt === "image_reveal"
|
: fmt === "image_reveal"
|
||||||
? "image"
|
? "image"
|
||||||
: "quiz"
|
: fmt === "rebus"
|
||||||
|
? "rebus"
|
||||||
|
: "quiz"
|
||||||
return {
|
return {
|
||||||
round: {
|
round: {
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "nerdware",
|
"name": "nerdware",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nerdware/shared",
|
"name": "@nerdware/shared",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
|
||||||
export type RoundType = "blindtest" | "quiz"
|
export type RoundType = "blindtest" | "quiz"
|
||||||
|
|
||||||
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
||||||
export type GameType = "mixed" | "quiz" | "image" | "blindtest"
|
export type GameType = "mixed" | "quiz" | "image" | "rebus" | "blindtest"
|
||||||
|
|
||||||
/** Sous-modes activables quand la partie est "mixed". */
|
/** Sous-modes activables quand la partie est "mixed". */
|
||||||
export type MixMode = "quiz" | "image" | "blindtest"
|
export type MixMode = "quiz" | "image" | "rebus" | "blindtest"
|
||||||
|
|
||||||
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||||
|
|
@ -20,7 +20,12 @@ export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||||
export type BotDifficulty = "easy" | "normal" | "hard"
|
export type BotDifficulty = "easy" | "normal" | "hard"
|
||||||
|
|
||||||
/** Formats de question quiz. */
|
/** Formats de question quiz. */
|
||||||
export type QuizFormat = "mcq" | "truefalse" | "free" | "image_reveal"
|
export type QuizFormat =
|
||||||
|
| "mcq"
|
||||||
|
| "truefalse"
|
||||||
|
| "free"
|
||||||
|
| "image_reveal"
|
||||||
|
| "rebus"
|
||||||
|
|
||||||
/** Joueur connecté à une room. */
|
/** Joueur connecté à une room. */
|
||||||
export interface Player {
|
export interface Player {
|
||||||
|
|
@ -34,8 +39,8 @@ export interface Player {
|
||||||
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
|
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
|
||||||
export interface RoundConfig {
|
export interface RoundConfig {
|
||||||
type: RoundType
|
type: RoundType
|
||||||
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique" ou images). */
|
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique", images ou rébus). */
|
||||||
pool?: "quiz" | "image"
|
pool?: "quiz" | "image" | "rebus"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue