mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-09-08 12:13:15 +02:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a25192034 | ||
|
|
84b269d2d1 | ||
|
|
760fc93c1b | ||
|
|
ab7ddfed4b | ||
|
|
3ac9d5ac39 | ||
|
|
a4fa783ed2 | ||
|
|
6b1b976790 | ||
|
|
fb2da91394 | ||
|
|
a23299a45e | ||
|
|
13c1e9c3f6 | ||
|
|
e4342bbdac | ||
|
|
61b47fa5d5 | ||
|
|
efbea1801c | ||
|
|
dba5024207 | ||
|
|
684a6a3c12 | ||
|
|
9d64f7f0b4 | ||
|
|
1679e68691 | ||
|
|
6c16dd146a | ||
|
|
f748d6ec3f | ||
|
|
a572eb35cd | ||
|
|
7bfb138f99 | ||
|
|
9a9ad640c0 | ||
|
|
3d247513ce | ||
|
|
497dd2ea4c |
@@ -8,7 +8,7 @@ export type Player = {
|
|||||||
|
|
||||||
export type Answer = {
|
export type Answer = {
|
||||||
playerId: string
|
playerId: string
|
||||||
answerIds: number[]
|
answerId: number
|
||||||
points: number
|
points: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export type Quizz = {
|
|||||||
image?: string
|
image?: string
|
||||||
media?: QuestionMedia
|
media?: QuestionMedia
|
||||||
answers: string[]
|
answers: string[]
|
||||||
solution: number | number[]
|
solution: number
|
||||||
cooldown: number
|
cooldown: number
|
||||||
time: number
|
time: number
|
||||||
}[]
|
}[]
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface ServerToClientEvents {
|
|||||||
"game:reset": (_message: string) => void
|
"game:reset": (_message: string) => void
|
||||||
"game:updateQuestion": (_data: { current: number; total: number }) => void
|
"game:updateQuestion": (_data: { current: number; total: number }) => void
|
||||||
"game:playerAnswer": (_count: number) => void
|
"game:playerAnswer": (_count: number) => void
|
||||||
|
"game:break": (_active: boolean) => void
|
||||||
|
|
||||||
// Player events
|
// Player events
|
||||||
"player:successReconnect": (_data: {
|
"player:successReconnect": (_data: {
|
||||||
@@ -66,6 +67,7 @@ export interface ServerToClientEvents {
|
|||||||
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
||||||
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
||||||
"manager:quizzDeleted": (_id: string) => void
|
"manager:quizzDeleted": (_id: string) => void
|
||||||
|
"manager:break": (_active: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
@@ -78,6 +80,7 @@ export interface ClientToServerEvents {
|
|||||||
"manager:abortQuiz": (_message: MessageGameId) => void
|
"manager:abortQuiz": (_message: MessageGameId) => void
|
||||||
"manager:pauseCooldown": (_message: MessageGameId) => void
|
"manager:pauseCooldown": (_message: MessageGameId) => void
|
||||||
"manager:resumeCooldown": (_message: MessageGameId) => void
|
"manager:resumeCooldown": (_message: MessageGameId) => void
|
||||||
|
"manager:setBreak": (_message: { gameId?: string; active: boolean }) => void
|
||||||
"manager:endGame": (_message: MessageGameId) => void
|
"manager:endGame": (_message: MessageGameId) => void
|
||||||
"manager:skipQuestionIntro": (_message: MessageGameId) => void
|
"manager:skipQuestionIntro": (_message: MessageGameId) => void
|
||||||
"manager:nextQuestion": (_message: MessageGameId) => void
|
"manager:nextQuestion": (_message: MessageGameId) => void
|
||||||
@@ -91,7 +94,7 @@ export interface ClientToServerEvents {
|
|||||||
"player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
|
"player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
|
||||||
"player:reconnect": (_message: { gameId: string }) => void
|
"player:reconnect": (_message: { gameId: string }) => void
|
||||||
"player:selectedAnswer": (
|
"player:selectedAnswer": (
|
||||||
_message: MessageWithoutStatus<{ answerKeys: number[] }>
|
_message: MessageWithoutStatus<{ answerKey: number }>
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export type CommonStatusDataMap = {
|
|||||||
media?: QuestionMedia
|
media?: QuestionMedia
|
||||||
time: number
|
time: number
|
||||||
totalPlayer: number
|
totalPlayer: number
|
||||||
allowsMultiple: boolean
|
|
||||||
}
|
}
|
||||||
SHOW_RESULT: {
|
SHOW_RESULT: {
|
||||||
correct: boolean
|
correct: boolean
|
||||||
@@ -50,7 +49,7 @@ type ManagerExtraStatus = {
|
|||||||
SHOW_RESPONSES: {
|
SHOW_RESPONSES: {
|
||||||
question: string
|
question: string
|
||||||
responses: Record<number, number>
|
responses: Record<number, number>
|
||||||
correct: number | number[]
|
correct: number
|
||||||
answers: string[]
|
answers: string[]
|
||||||
image?: string
|
image?: string
|
||||||
media?: QuestionMedia
|
media?: QuestionMedia
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ io.on("connection", (socket) => {
|
|||||||
|
|
||||||
socket.on("player:selectedAnswer", ({ gameId, data }) =>
|
socket.on("player:selectedAnswer", ({ gameId, data }) =>
|
||||||
withGame(gameId, socket, (game) =>
|
withGame(gameId, socket, (game) =>
|
||||||
game.selectAnswer(socket, data.answerKeys)
|
game.selectAnswer(socket, data.answerKey)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -234,6 +234,10 @@ io.on("connection", (socket) => {
|
|||||||
withGame(gameId, socket, (game) => game.resumeCooldown(socket))
|
withGame(gameId, socket, (game) => game.resumeCooldown(socket))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
socket.on("manager:setBreak", ({ gameId, active }) =>
|
||||||
|
withGame(gameId, socket, (game) => game.setBreak(socket, active))
|
||||||
|
)
|
||||||
|
|
||||||
socket.on("manager:endGame", ({ gameId }) =>
|
socket.on("manager:endGame", ({ gameId }) =>
|
||||||
withGame(gameId, socket, (game) => game.endGame(socket, registry))
|
withGame(gameId, socket, (game) => game.endGame(socket, registry))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ class Config {
|
|||||||
if (!isMediaExists) {
|
if (!isMediaExists) {
|
||||||
fs.mkdirSync(getPath("media"))
|
fs.mkdirSync(getPath("media"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isThemeExists = fs.existsSync(getPath("theme.json"))
|
||||||
|
|
||||||
|
if (!isThemeExists) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
getPath("theme.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
brandName: "Rahoot",
|
||||||
|
backgroundUrl: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static init() {
|
static init() {
|
||||||
@@ -104,7 +120,7 @@ class Config {
|
|||||||
type: "audio",
|
type: "audio",
|
||||||
url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3",
|
url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3",
|
||||||
},
|
},
|
||||||
solution: [1],
|
solution: 1,
|
||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
time: 25,
|
time: 25,
|
||||||
},
|
},
|
||||||
@@ -120,7 +136,7 @@ class Config {
|
|||||||
type: "video",
|
type: "video",
|
||||||
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
||||||
},
|
},
|
||||||
solution: [2],
|
solution: 2,
|
||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
time: 40,
|
time: 40,
|
||||||
},
|
},
|
||||||
@@ -151,6 +167,17 @@ class Config {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static theme() {
|
||||||
|
this.ensureBaseFolders()
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(getPath("theme.json"), "utf-8")
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to read theme config:", error)
|
||||||
|
return { brandName: "Rahoot", backgroundUrl: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static quizz() {
|
static quizz() {
|
||||||
this.ensureBaseFolders()
|
this.ensureBaseFolders()
|
||||||
|
|
||||||
@@ -231,6 +258,17 @@ class Config {
|
|||||||
|
|
||||||
return getPath(fileName ? `media/${fileName}` : "media")
|
return getPath(fileName ? `media/${fileName}` : "media")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static saveTheme(theme: { brandName?: string; backgroundUrl?: string | null }) {
|
||||||
|
this.ensureBaseFolders()
|
||||||
|
const next = {
|
||||||
|
brandName: theme.brandName || "Rahoot",
|
||||||
|
backgroundUrl: theme.backgroundUrl ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(getPath("theme.json"), JSON.stringify(next, null, 2))
|
||||||
|
return next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Config
|
export default Config
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Game {
|
|||||||
timer: NodeJS.Timeout | null
|
timer: NodeJS.Timeout | null
|
||||||
resolve: (() => void) | null
|
resolve: (() => void) | null
|
||||||
}
|
}
|
||||||
|
breakActive: boolean
|
||||||
|
|
||||||
constructor(io: Server, socket: Socket, quizz: Quizz) {
|
constructor(io: Server, socket: Socket, quizz: Quizz) {
|
||||||
if (!io) {
|
if (!io) {
|
||||||
@@ -84,6 +85,7 @@ class Game {
|
|||||||
timer: null,
|
timer: null,
|
||||||
resolve: null,
|
resolve: null,
|
||||||
}
|
}
|
||||||
|
this.breakActive = false
|
||||||
|
|
||||||
const roomInvite = createInviteCode()
|
const roomInvite = createInviteCode()
|
||||||
this.inviteCode = roomInvite
|
this.inviteCode = roomInvite
|
||||||
@@ -125,25 +127,11 @@ class Game {
|
|||||||
id: "",
|
id: "",
|
||||||
connected: false,
|
connected: false,
|
||||||
}))
|
}))
|
||||||
const round = snapshot.round || {
|
game.round = snapshot.round || {
|
||||||
playersAnswers: [],
|
playersAnswers: [],
|
||||||
currentQuestion: 0,
|
currentQuestion: 0,
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
}
|
}
|
||||||
const migratedAnswers = Array.isArray(round.playersAnswers)
|
|
||||||
? round.playersAnswers.map((a: any) => ({
|
|
||||||
...a,
|
|
||||||
answerIds: Array.isArray(a.answerIds)
|
|
||||||
? a.answerIds
|
|
||||||
: typeof a.answerId === "number"
|
|
||||||
? [a.answerId]
|
|
||||||
: [],
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
game.round = {
|
|
||||||
...round,
|
|
||||||
playersAnswers: migratedAnswers,
|
|
||||||
}
|
|
||||||
game.cooldown = {
|
game.cooldown = {
|
||||||
active: snapshot.cooldown?.active || false,
|
active: snapshot.cooldown?.active || false,
|
||||||
paused: snapshot.cooldown?.paused || false,
|
paused: snapshot.cooldown?.paused || false,
|
||||||
@@ -151,6 +139,7 @@ class Game {
|
|||||||
timer: null,
|
timer: null,
|
||||||
resolve: null,
|
resolve: null,
|
||||||
}
|
}
|
||||||
|
game.breakActive = snapshot.breakActive || false
|
||||||
|
|
||||||
if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) {
|
if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) {
|
||||||
game.startCooldown(game.cooldown.remaining)
|
game.startCooldown(game.cooldown.remaining)
|
||||||
@@ -207,6 +196,7 @@ class Game {
|
|||||||
paused: this.cooldown.paused,
|
paused: this.cooldown.paused,
|
||||||
remaining: this.cooldown.remaining,
|
remaining: this.cooldown.remaining,
|
||||||
},
|
},
|
||||||
|
breakActive: this.breakActive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +314,10 @@ class Game {
|
|||||||
players: this.players,
|
players: this.players,
|
||||||
})
|
})
|
||||||
socket.emit("game:totalPlayers", this.players.length)
|
socket.emit("game:totalPlayers", this.players.length)
|
||||||
|
if (this.breakActive) {
|
||||||
|
socket.emit("manager:break", true)
|
||||||
|
socket.emit("game:break", true)
|
||||||
|
}
|
||||||
|
|
||||||
registry.reactivateGame(this.gameId)
|
registry.reactivateGame(this.gameId)
|
||||||
console.log(`Manager reconnected to game ${this.inviteCode}`)
|
console.log(`Manager reconnected to game ${this.inviteCode}`)
|
||||||
@@ -375,6 +369,9 @@ class Game {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
socket.emit("game:totalPlayers", this.players.length)
|
socket.emit("game:totalPlayers", this.players.length)
|
||||||
|
if (this.breakActive) {
|
||||||
|
socket.emit("game:break", true)
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`Player ${player.username} reconnected to game ${this.inviteCode}`
|
`Player ${player.username} reconnected to game ${this.inviteCode}`
|
||||||
)
|
)
|
||||||
@@ -467,6 +464,27 @@ class Game {
|
|||||||
this.persist()
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBreak(socket: Socket, active: boolean) {
|
||||||
|
if (this.manager.id !== socket.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.breakActive = active
|
||||||
|
|
||||||
|
if (this.cooldown.active) {
|
||||||
|
if (active) {
|
||||||
|
this.cooldown.paused = true
|
||||||
|
} else {
|
||||||
|
this.cooldown.paused = false
|
||||||
|
}
|
||||||
|
this.io.to(this.gameId).emit("game:cooldownPause", this.cooldown.paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.io.to(this.gameId).emit("game:break", active)
|
||||||
|
this.io.to(this.manager.id).emit("manager:break", active)
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
skipQuestionIntro(socket: Socket) {
|
skipQuestionIntro(socket: Socket) {
|
||||||
if (this.manager.id !== socket.id) {
|
if (this.manager.id !== socket.id) {
|
||||||
return
|
return
|
||||||
@@ -552,8 +570,6 @@ class Game {
|
|||||||
media: question.media,
|
media: question.media,
|
||||||
time: question.time,
|
time: question.time,
|
||||||
totalPlayer: this.players.length,
|
totalPlayer: this.players.length,
|
||||||
allowsMultiple:
|
|
||||||
Array.isArray(question.solution) && question.solution.length > 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.startCooldown(question.time)
|
await this.startCooldown(question.time)
|
||||||
@@ -573,10 +589,9 @@ class Game {
|
|||||||
: this.leaderboard.map((p) => ({ ...p }))
|
: this.leaderboard.map((p) => ({ ...p }))
|
||||||
|
|
||||||
const totalType = this.round.playersAnswers.reduce(
|
const totalType = this.round.playersAnswers.reduce(
|
||||||
(acc: Record<number, number>, { answerIds }) => {
|
(acc: Record<number, number>, { answerId }) => {
|
||||||
answerIds.forEach((id) => {
|
acc[answerId] = (acc[answerId] || 0) + 1
|
||||||
acc[id] = (acc[id] || 0) + 1
|
|
||||||
})
|
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
@@ -588,16 +603,8 @@ class Game {
|
|||||||
(a) => a.playerId === player.id
|
(a) => a.playerId === player.id
|
||||||
)
|
)
|
||||||
|
|
||||||
const correctAnswers = Array.isArray(question.solution)
|
|
||||||
? Array.from(new Set(question.solution))
|
|
||||||
: [question.solution]
|
|
||||||
|
|
||||||
const isCorrect = playerAnswer
|
const isCorrect = playerAnswer
|
||||||
? (() => {
|
? playerAnswer.answerId === question.solution
|
||||||
const chosen = Array.from(new Set(playerAnswer.answerIds))
|
|
||||||
if (chosen.length !== correctAnswers.length) return false
|
|
||||||
return correctAnswers.every((id: number) => chosen.includes(id))
|
|
||||||
})()
|
|
||||||
: false
|
: false
|
||||||
|
|
||||||
const points =
|
const points =
|
||||||
@@ -640,7 +647,7 @@ class Game {
|
|||||||
this.round.playersAnswers = []
|
this.round.playersAnswers = []
|
||||||
this.persist()
|
this.persist()
|
||||||
}
|
}
|
||||||
selectAnswer(socket: Socket, answerIds: number[]) {
|
selectAnswer(socket: Socket, answerId: number) {
|
||||||
const player = this.players.find((player) => player.id === socket.id)
|
const player = this.players.find((player) => player.id === socket.id)
|
||||||
const question = this.quizz.questions[this.round.currentQuestion]
|
const question = this.quizz.questions[this.round.currentQuestion]
|
||||||
|
|
||||||
@@ -652,17 +659,9 @@ class Game {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueAnswers = Array.from(new Set(answerIds)).filter(
|
|
||||||
(id) => !Number.isNaN(id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (uniqueAnswers.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.round.playersAnswers.push({
|
this.round.playersAnswers.push({
|
||||||
playerId: player.id,
|
playerId: player.id,
|
||||||
answerIds: uniqueAnswers,
|
answerId,
|
||||||
points: timeToPoint(this.round.startTime, question.time),
|
points: timeToPoint(this.round.startTime, question.time),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import logo from "@rahoot/web/assets/logo.svg"
|
import BrandHeading from "@rahoot/web/components/BrandHeading"
|
||||||
import Loader from "@rahoot/web/components/Loader"
|
import Loader from "@rahoot/web/components/Loader"
|
||||||
import { useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import Image from "next/image"
|
|
||||||
import { PropsWithChildren, useEffect } from "react"
|
import { PropsWithChildren, useEffect } from "react"
|
||||||
|
|
||||||
const AuthLayout = ({ children }: PropsWithChildren) => {
|
const AuthLayout = ({ children }: PropsWithChildren) => {
|
||||||
@@ -14,34 +13,34 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
|
|||||||
}
|
}
|
||||||
}, [connect, isConnected])
|
}, [connect, isConnected])
|
||||||
|
|
||||||
|
const Shell = ({ children: inner }: PropsWithChildren) => (
|
||||||
|
<section className="relative flex min-h-screen flex-col items-center justify-center px-4 text-center">
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full" />
|
||||||
|
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="z-10 flex w-full flex-col items-center gap-8 px-4">
|
||||||
|
<BrandHeading size="md" />
|
||||||
|
<div className="flex w-full justify-center">{inner}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
<Shell>
|
||||||
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
|
<Loader className="h-23" />
|
||||||
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
|
<h2 className="text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
|
||||||
|
Loading...
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
</Shell>
|
||||||
<Image src={logo} className="mb-6 h-32" alt="logo" />
|
|
||||||
<Loader className="h-23" />
|
|
||||||
<h2 className="mt-2 text-center text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
|
|
||||||
Loading...
|
|
||||||
</h2>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <Shell>{children}</Shell>
|
||||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
|
||||||
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
|
|
||||||
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
|
|
||||||
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Image src={logo} className="mb-6 h-32" alt="logo" />
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AuthLayout
|
export default AuthLayout
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import { STATUS } from "@rahoot/common/types/game/status"
|
|||||||
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
|
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
|
||||||
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
|
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
|
||||||
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary"
|
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary"
|
||||||
|
import ThemeEditor from "@rahoot/web/components/game/create/ThemeEditor"
|
||||||
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
||||||
|
import Button from "@rahoot/web/components/Button"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { useManagerStore } from "@rahoot/web/stores/manager"
|
import { useManagerStore } from "@rahoot/web/stores/manager"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
const Manager = () => {
|
const Manager = () => {
|
||||||
const { setGameId, setStatus } = useManagerStore()
|
const { gameId, setGameId, setStatus } = useManagerStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ const Manager = () => {
|
|||||||
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
|
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
|
||||||
const [showEditor, setShowEditor] = useState(false)
|
const [showEditor, setShowEditor] = useState(false)
|
||||||
const [showMedia, setShowMedia] = useState(false)
|
const [showMedia, setShowMedia] = useState(false)
|
||||||
|
const [showTheme, setShowTheme] = useState(false)
|
||||||
|
const [resumeGameId, setResumeGameId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEvent("manager:quizzList", (quizzList) => {
|
useEvent("manager:quizzList", (quizzList) => {
|
||||||
setIsAuth(true)
|
setIsAuth(true)
|
||||||
@@ -29,15 +33,44 @@ const Manager = () => {
|
|||||||
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
|
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
|
||||||
setGameId(gameId)
|
setGameId(gameId)
|
||||||
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
|
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
|
||||||
|
setResumeGameId(gameId)
|
||||||
router.push(`/game/manager/${gameId}`)
|
router.push(`/game/manager/${gameId}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEvent(
|
||||||
|
"manager:successReconnect",
|
||||||
|
({ gameId, status, players, currentQuestion }) => {
|
||||||
|
setGameId(gameId)
|
||||||
|
setStatus(status.name, status.data)
|
||||||
|
setResumeGameId(gameId)
|
||||||
|
router.push(`/game/manager/${gameId}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const handleAuth = (password: string) => {
|
const handleAuth = (password: string) => {
|
||||||
socket?.emit("manager:auth", password)
|
socket?.emit("manager:auth", password)
|
||||||
}
|
}
|
||||||
const handleCreate = (quizzId: string) => {
|
const handleCreate = (quizzId: string) => {
|
||||||
socket?.emit("game:create", quizzId)
|
socket?.emit("game:create", quizzId)
|
||||||
}
|
}
|
||||||
|
const handleBreakToggle = (active: boolean) => {
|
||||||
|
if (!gameId) return
|
||||||
|
socket?.emit("manager:setBreak", { gameId, active })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResume = () => {
|
||||||
|
if (!resumeGameId) return
|
||||||
|
socket?.emit("manager:reconnect", { gameId: resumeGameId })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("last_manager_game_id")
|
||||||
|
if (stored) {
|
||||||
|
setResumeGameId(stored)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
return <ManagerPassword onSubmit={handleAuth} />
|
return <ManagerPassword onSubmit={handleAuth} />
|
||||||
@@ -49,32 +82,42 @@ const Manager = () => {
|
|||||||
quizzList={quizzList}
|
quizzList={quizzList}
|
||||||
onBack={() => setShowEditor(false)}
|
onBack={() => setShowEditor(false)}
|
||||||
onListUpdate={setQuizzList}
|
onListUpdate={setQuizzList}
|
||||||
|
onBreakToggle={handleBreakToggle}
|
||||||
|
gameId={gameId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showMedia) {
|
if (showMedia) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Button onClick={() => setShowMedia(false)} className="bg-gray-700">
|
||||||
onClick={() => setShowMedia(false)}
|
|
||||||
className="rounded-md bg-gray-700 px-3 py-2 text-white"
|
|
||||||
>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Media library</h2>
|
||||||
|
<p className="text-xs text-gray-500">Upload, view, and delete unused media.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MediaLibrary />
|
<MediaLibrary />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTheme) {
|
||||||
|
return <ThemeEditor onBack={() => setShowTheme(false)} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectQuizz
|
<SelectQuizz
|
||||||
quizzList={quizzList}
|
quizzList={quizzList}
|
||||||
onSelect={handleCreate}
|
onSelect={handleCreate}
|
||||||
onManage={() => setShowEditor(true)}
|
onManage={() => setShowEditor(true)}
|
||||||
onMedia={() => setShowMedia(true)}
|
onMedia={() => setShowMedia(true)}
|
||||||
|
onTheme={() => setShowTheme(true)}
|
||||||
|
resumeGameId={resumeGameId}
|
||||||
|
onResume={handleResume}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getTheme, saveTheme } from "@rahoot/web/server/theme"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const theme = getTheme()
|
||||||
|
return NextResponse.json({ theme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load theme", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load theme" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const theme = saveTheme({
|
||||||
|
brandName: body.brandName,
|
||||||
|
backgroundUrl: body.backgroundUrl,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ theme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save theme", error)
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to save theme"
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const ManagerGame = () => {
|
|||||||
useManagerStore()
|
useManagerStore()
|
||||||
const { setQuestionStates } = useQuestionStore()
|
const { setQuestionStates } = useQuestionStore()
|
||||||
const [cooldownPaused, setCooldownPaused] = useState(false)
|
const [cooldownPaused, setCooldownPaused] = useState(false)
|
||||||
|
const [breakActive, setBreakActive] = useState(false)
|
||||||
const { players } = useManagerStore()
|
const { players } = useManagerStore()
|
||||||
|
|
||||||
useEvent("game:status", ({ name, data }) => {
|
useEvent("game:status", ({ name, data }) => {
|
||||||
@@ -73,6 +74,9 @@ const ManagerGame = () => {
|
|||||||
setCooldownPaused(isPaused)
|
setCooldownPaused(isPaused)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEvent("game:break", (active) => setBreakActive(active))
|
||||||
|
useEvent("manager:break", (active) => setBreakActive(active))
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
if (!gameId) {
|
if (!gameId) {
|
||||||
return
|
return
|
||||||
@@ -115,6 +119,11 @@ const ManagerGame = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBreakToggle = () => {
|
||||||
|
if (!gameId) return
|
||||||
|
socket?.emit("manager:setBreak", { gameId, active: !breakActive })
|
||||||
|
}
|
||||||
|
|
||||||
const handleEndGame = () => {
|
const handleEndGame = () => {
|
||||||
if (!gameId) return
|
if (!gameId) return
|
||||||
socket?.emit("manager:endGame", { gameId })
|
socket?.emit("manager:endGame", { gameId })
|
||||||
@@ -173,6 +182,8 @@ const ManagerGame = () => {
|
|||||||
showPause={
|
showPause={
|
||||||
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
|
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
|
||||||
}
|
}
|
||||||
|
onBreakToggle={handleBreakToggle}
|
||||||
|
breakActive={breakActive}
|
||||||
onEnd={handleEndGame}
|
onEnd={handleEndGame}
|
||||||
players={players}
|
players={players}
|
||||||
manager
|
manager
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import Toaster from "@rahoot/web/components/Toaster"
|
import Toaster from "@rahoot/web/components/Toaster"
|
||||||
|
import BrandingHelmet from "@rahoot/web/components/BrandingHelmet"
|
||||||
|
import ThemeHydrator from "@rahoot/web/components/ThemeHydrator"
|
||||||
import { SocketProvider } from "@rahoot/web/contexts/socketProvider"
|
import { SocketProvider } from "@rahoot/web/contexts/socketProvider"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { Montserrat } from "next/font/google"
|
import { Montserrat } from "next/font/google"
|
||||||
import { PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
import { getTheme } from "@rahoot/web/server/theme"
|
||||||
|
|
||||||
const montserrat = Montserrat({
|
const montserrat = Montserrat({
|
||||||
variable: "--font-montserrat",
|
variable: "--font-montserrat",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Rahoot !",
|
const theme = getTheme()
|
||||||
icons: "/icon.svg",
|
return {
|
||||||
|
title: theme.brandName || "Rahoot",
|
||||||
|
icons: "/icon.svg",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const RootLayout = ({ children }: PropsWithChildren) => (
|
const RootLayout = ({ children }: PropsWithChildren) => (
|
||||||
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
|
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
|
||||||
<body className={`${montserrat.variable} bg-secondary antialiased`}>
|
<body className={`${montserrat.variable} bg-secondary antialiased`}>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
|
<BrandingHelmet />
|
||||||
|
<ThemeHydrator />
|
||||||
<main className="text-base-[8px] flex flex-col">{children}</main>
|
<main className="text-base-[8px] flex flex-col">{children}</main>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
|
|||||||
@@ -4,20 +4,17 @@ import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react"
|
|||||||
type Props = PropsWithChildren &
|
type Props = PropsWithChildren &
|
||||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
icon: ElementType
|
icon: ElementType
|
||||||
selected?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnswerButton = ({
|
const AnswerButton = ({
|
||||||
className,
|
className,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
children,
|
children,
|
||||||
selected = false,
|
|
||||||
...otherProps
|
...otherProps
|
||||||
}: Props) => (
|
}: Props) => (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left transition-all",
|
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left",
|
||||||
selected && "ring-4 ring-white/80 shadow-lg",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
import clsx from "clsx"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
size?: "md" | "lg"
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandHeading = ({ className, size = "lg" }: Props) => {
|
||||||
|
const { brandName } = useThemeStore()
|
||||||
|
const label = brandName || "Rahoot!"
|
||||||
|
|
||||||
|
const sizeClass =
|
||||||
|
size === "lg"
|
||||||
|
? "text-4xl md:text-5xl lg:text-6xl"
|
||||||
|
: "text-2xl md:text-3xl"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"text-center font-black tracking-tight text-[#f7931e] drop-shadow-lg leading-tight",
|
||||||
|
sizeClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandHeading
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
const BrandingHelmet = () => {
|
||||||
|
const { brandName } = useThemeStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (brandName) {
|
||||||
|
document.title = brandName
|
||||||
|
}
|
||||||
|
}, [brandName])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandingHelmet
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
const Form = ({ children }: PropsWithChildren) => (
|
const Form = ({ children }: PropsWithChildren) => (
|
||||||
<div className="z-10 flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
<div className="z-10 mx-auto flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
|
||||||
|
const ThemeHydrator = () => {
|
||||||
|
const { setBackground, setBrandName } = useThemeStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/theme", { cache: "no-store" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.theme) {
|
||||||
|
if (typeof data.theme.backgroundUrl === "string") {
|
||||||
|
setBackground(data.theme.backgroundUrl || null)
|
||||||
|
}
|
||||||
|
if (typeof data.theme.brandName === "string") {
|
||||||
|
setBrandName(data.theme.brandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to hydrate theme", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [setBackground, setBrandName])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeHydrator
|
||||||
@@ -7,9 +7,9 @@ import Loader from "@rahoot/web/components/Loader"
|
|||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
import { useQuestionStore } from "@rahoot/web/stores/question"
|
import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
|
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import Image from "next/image"
|
|
||||||
import { PropsWithChildren, useEffect, useState } from "react"
|
import { PropsWithChildren, useEffect, useState } from "react"
|
||||||
|
|
||||||
type Props = PropsWithChildren & {
|
type Props = PropsWithChildren & {
|
||||||
@@ -21,6 +21,8 @@ type Props = PropsWithChildren & {
|
|||||||
onEnd?: () => void
|
onEnd?: () => void
|
||||||
players?: { id: string; username: string; connected: boolean }[]
|
players?: { id: string; username: string; connected: boolean }[]
|
||||||
manager?: boolean
|
manager?: boolean
|
||||||
|
onBreakToggle?: () => void
|
||||||
|
breakActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameWrapper = ({
|
const GameWrapper = ({
|
||||||
@@ -33,11 +35,15 @@ const GameWrapper = ({
|
|||||||
onEnd,
|
onEnd,
|
||||||
players,
|
players,
|
||||||
manager,
|
manager,
|
||||||
|
onBreakToggle,
|
||||||
|
breakActive,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { isConnected } = useSocket()
|
const { isConnected } = useSocket()
|
||||||
const { player } = usePlayerStore()
|
const { player } = usePlayerStore()
|
||||||
const { questionStates, setQuestionStates } = useQuestionStore()
|
const { questionStates, setQuestionStates } = useQuestionStore()
|
||||||
|
const { backgroundUrl, setBackground, setBrandName } = useThemeStore()
|
||||||
const [isDisabled, setIsDisabled] = useState(false)
|
const [isDisabled, setIsDisabled] = useState(false)
|
||||||
|
const [onBreak, setOnBreak] = useState(false)
|
||||||
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
|
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
|
||||||
|
|
||||||
useEvent("game:updateQuestion", ({ current, total }) => {
|
useEvent("game:updateQuestion", ({ current, total }) => {
|
||||||
@@ -51,19 +57,50 @@ const GameWrapper = ({
|
|||||||
setIsDisabled(false)
|
setIsDisabled(false)
|
||||||
}, [statusName])
|
}, [statusName])
|
||||||
|
|
||||||
|
useEvent("game:break", (active) => setOnBreak(active))
|
||||||
|
useEvent("manager:break", (active) => setOnBreak(active))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTheme = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/theme", { cache: "no-store" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.theme) {
|
||||||
|
if (typeof data.theme.backgroundUrl === "string") {
|
||||||
|
setBackground(data.theme.backgroundUrl || null)
|
||||||
|
}
|
||||||
|
if (typeof data.theme.brandName === "string") {
|
||||||
|
setBrandName(data.theme.brandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load theme", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTheme()
|
||||||
|
}, [setBackground, setBrandName])
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
setIsDisabled(true)
|
setIsDisabled(true)
|
||||||
onNext?.()
|
onNext?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedBackground = backgroundUrl || background.src
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-screen w-full flex-col justify-between">
|
<section className="relative flex min-h-screen w-full flex-col justify-between">
|
||||||
<div className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70">
|
<div
|
||||||
<Image
|
className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70"
|
||||||
className="pointer-events-none h-full w-full object-cover opacity-60"
|
style={{
|
||||||
src={background}
|
backgroundImage: `url(${resolvedBackground})`,
|
||||||
alt="background"
|
backgroundSize: "cover",
|
||||||
/>
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full bg-black/10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isConnected && !statusName ? (
|
{!isConnected && !statusName ? (
|
||||||
@@ -102,6 +139,17 @@ const GameWrapper = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{manager && onBreakToggle && (
|
||||||
|
<Button
|
||||||
|
className={clsx("self-end bg-white px-4 text-black!", {
|
||||||
|
"pointer-events-none": isDisabled,
|
||||||
|
})}
|
||||||
|
onClick={onBreakToggle}
|
||||||
|
>
|
||||||
|
{breakActive ? "Resume game" : "Break"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{manager && onEnd && (
|
{manager && onEnd && (
|
||||||
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
|
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
|
||||||
End game
|
End game
|
||||||
@@ -142,6 +190,15 @@ const GameWrapper = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onBreak && (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||||
|
<div className="rounded-md bg-white/90 px-6 py-4 text-center shadow-lg">
|
||||||
|
<p className="text-lg font-semibold text-gray-800">Game paused for a break</p>
|
||||||
|
<p className="text-sm text-gray-600">We'll resume from the same spot.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const ManagerPassword = ({ onSubmit }: Props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form>
|
<div className="w-full max-w-xl">
|
||||||
|
<Form>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -35,7 +36,8 @@ const ManagerPassword = ({ onSubmit }: Props) => {
|
|||||||
placeholder="Manager password"
|
placeholder="Manager password"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleSubmit}>Submit</Button>
|
<Button onClick={handleSubmit}>Submit</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type Props = {
|
|||||||
quizzList: QuizzWithId[]
|
quizzList: QuizzWithId[]
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onListUpdate: (_quizz: QuizzWithId[]) => void
|
onListUpdate: (_quizz: QuizzWithId[]) => void
|
||||||
|
onBreakToggle?: (_active: boolean) => void
|
||||||
|
gameId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditableQuestion = QuizzWithId["questions"][number]
|
type EditableQuestion = QuizzWithId["questions"][number]
|
||||||
@@ -33,7 +35,7 @@ type MediaLibraryItem = {
|
|||||||
const blankQuestion = (): EditableQuestion => ({
|
const blankQuestion = (): EditableQuestion => ({
|
||||||
question: "",
|
question: "",
|
||||||
answers: ["", ""],
|
answers: ["", ""],
|
||||||
solution: [0],
|
solution: 0,
|
||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
time: 20,
|
time: 20,
|
||||||
})
|
})
|
||||||
@@ -55,7 +57,13 @@ const formatBytes = (bytes: number) => {
|
|||||||
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
|
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
const QuizEditor = ({
|
||||||
|
quizzList,
|
||||||
|
onBack,
|
||||||
|
onListUpdate,
|
||||||
|
onBreakToggle,
|
||||||
|
gameId,
|
||||||
|
}: Props) => {
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [draft, setDraft] = useState<QuizzWithId | null>(null)
|
const [draft, setDraft] = useState<QuizzWithId | null>(null)
|
||||||
@@ -186,14 +194,10 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentAnswers.splice(aIndex, 1)
|
currentAnswers.splice(aIndex, 1)
|
||||||
const currentSolution = Array.isArray(nextQuestions[qIndex].solution)
|
let nextSolution = nextQuestions[qIndex].solution
|
||||||
? nextQuestions[qIndex].solution
|
if (nextSolution >= currentAnswers.length) {
|
||||||
: [nextQuestions[qIndex].solution]
|
nextSolution = currentAnswers.length - 1
|
||||||
const adjusted = currentSolution
|
}
|
||||||
.filter((idx) => idx !== aIndex)
|
|
||||||
.map((idx) => (idx > aIndex ? idx - 1 : idx))
|
|
||||||
const nextSolution =
|
|
||||||
adjusted.length > 0 ? adjusted : [Math.max(0, currentAnswers.length - 1)]
|
|
||||||
nextQuestions[qIndex] = {
|
nextQuestions[qIndex] = {
|
||||||
...nextQuestions[qIndex],
|
...nextQuestions[qIndex],
|
||||||
answers: currentAnswers,
|
answers: currentAnswers,
|
||||||
@@ -464,21 +468,31 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
}, [quizzList, selectedId])
|
}, [quizzList, selectedId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-7 rounded-md bg-white p-6 shadow-sm md:p-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={onBack} className="bg-gray-700">
|
<Button onClick={onBack} className="bg-gray-700">
|
||||||
Back
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNew} className="bg-blue-600">
|
||||||
|
New quiz
|
||||||
|
</Button>
|
||||||
|
{selectedId && (
|
||||||
|
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
|
||||||
|
Delete quiz
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleNew} className="bg-blue-600">
|
)}
|
||||||
New quiz
|
{onBreakToggle && gameId && (
|
||||||
</Button>
|
<>
|
||||||
{selectedId && (
|
<Button className="bg-amber-500" onClick={() => onBreakToggle(true)}>
|
||||||
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
|
Break
|
||||||
Delete quiz
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button className="bg-green-600" onClick={() => onBreakToggle(false)}>
|
||||||
</div>
|
Resume
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSave} disabled={saving || loading}>
|
<Button onClick={handleSave} disabled={saving || loading}>
|
||||||
{saving ? "Saving..." : "Save quiz"}
|
{saving ? "Saving..." : "Save quiz"}
|
||||||
@@ -739,47 +753,30 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{question.answers.map((answer, aIndex) => (
|
{question.answers.map((answer, aIndex) => (
|
||||||
<div
|
<div
|
||||||
key={aIndex}
|
key={aIndex}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 rounded-md border p-2",
|
"flex items-center gap-2 rounded-md border p-2",
|
||||||
(Array.isArray(question.solution)
|
question.solution === aIndex
|
||||||
? question.solution.includes(aIndex)
|
? "border-green-500"
|
||||||
: question.solution === aIndex)
|
: "border-gray-200",
|
||||||
? "border-green-500"
|
)}
|
||||||
: "border-gray-200",
|
>
|
||||||
)}
|
<input
|
||||||
>
|
type="radio"
|
||||||
<input
|
name={`solution-${qIndex}`}
|
||||||
type="checkbox"
|
checked={question.solution === aIndex}
|
||||||
name={`solution-${qIndex}-${aIndex}`}
|
onChange={() =>
|
||||||
checked={
|
updateQuestion(qIndex, { solution: aIndex })
|
||||||
Array.isArray(question.solution)
|
|
||||||
? question.solution.includes(aIndex)
|
|
||||||
: question.solution === aIndex
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
const current = Array.isArray(question.solution)
|
|
||||||
? question.solution
|
|
||||||
: [question.solution]
|
|
||||||
let next = current
|
|
||||||
if (e.target.checked) {
|
|
||||||
next = Array.from(new Set([...current, aIndex])).sort(
|
|
||||||
(a, b) => a - b,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
next = current.filter((idx) => idx !== aIndex)
|
|
||||||
}
|
}
|
||||||
updateQuestion(qIndex, { solution: next })
|
/>
|
||||||
}}
|
<Input
|
||||||
/>
|
className="flex-1"
|
||||||
<Input
|
value={answer}
|
||||||
className="flex-1"
|
onChange={(e) =>
|
||||||
value={answer}
|
updateAnswer(qIndex, aIndex, e.target.value)
|
||||||
onChange={(e) =>
|
|
||||||
updateAnswer(qIndex, aIndex, e.target.value)
|
|
||||||
}
|
}
|
||||||
placeholder={`Answer ${aIndex + 1}`}
|
placeholder={`Answer ${aIndex + 1}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ type Props = {
|
|||||||
onSelect: (_id: string) => void
|
onSelect: (_id: string) => void
|
||||||
onManage?: () => void
|
onManage?: () => void
|
||||||
onMedia?: () => void
|
onMedia?: () => void
|
||||||
|
onTheme?: () => void
|
||||||
|
resumeGameId?: string | null
|
||||||
|
onResume?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
|
const SelectQuizz = ({
|
||||||
|
quizzList,
|
||||||
|
onSelect,
|
||||||
|
onManage,
|
||||||
|
onMedia,
|
||||||
|
onTheme,
|
||||||
|
resumeGameId,
|
||||||
|
onResume,
|
||||||
|
}: Props) => {
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleSelect = (id: string) => () => {
|
const handleSelect = (id: string) => () => {
|
||||||
@@ -37,6 +48,14 @@ const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Select a quizz</h1>
|
<h1 className="text-2xl font-bold">Select a quizz</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{resumeGameId && onResume && (
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-white shadow"
|
||||||
|
onClick={onResume}
|
||||||
|
>
|
||||||
|
Resume game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onMedia && (
|
{onMedia && (
|
||||||
<button
|
<button
|
||||||
className="text-sm font-semibold text-gray-700 underline"
|
className="text-sm font-semibold text-gray-700 underline"
|
||||||
@@ -45,6 +64,14 @@ const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
|
|||||||
Media
|
Media
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onTheme && (
|
||||||
|
<button
|
||||||
|
className="text-sm font-semibold text-amber-700 underline"
|
||||||
|
onClick={onTheme}
|
||||||
|
>
|
||||||
|
Theme
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onManage && (
|
{onManage && (
|
||||||
<button
|
<button
|
||||||
className="text-sm font-semibold text-primary underline"
|
className="text-sm font-semibold text-primary underline"
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import background from "@rahoot/web/assets/background.webp"
|
||||||
|
import Button from "@rahoot/web/components/Button"
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
import clsx from "clsx"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
type MediaItem = {
|
||||||
|
fileName: string
|
||||||
|
url: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeEditor = ({ onBack }: Props) => {
|
||||||
|
const { backgroundUrl, brandName, setBackground, setBrandName, reset } = useThemeStore()
|
||||||
|
const [customUrl, setCustomUrl] = useState("")
|
||||||
|
const [items, setItems] = useState<MediaItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
const [savingTheme, setSavingTheme] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [initializing, setInitializing] = useState(true)
|
||||||
|
|
||||||
|
const previewUrl = useMemo(
|
||||||
|
() => backgroundUrl || customUrl || background.src,
|
||||||
|
[backgroundUrl, customUrl],
|
||||||
|
)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [mediaRes, themeRes] = await Promise.all([
|
||||||
|
fetch("/api/media", { cache: "no-store" }),
|
||||||
|
fetch("/api/theme", { cache: "no-store" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const mediaData = await mediaRes.json()
|
||||||
|
const themeData = await themeRes.json()
|
||||||
|
|
||||||
|
if (!mediaRes.ok) throw new Error(mediaData.error || "Failed to load media")
|
||||||
|
if (!themeRes.ok) throw new Error(themeData.error || "Failed to load theme")
|
||||||
|
|
||||||
|
const onlyImages = (mediaData.media || []).filter(
|
||||||
|
(item: MediaItem) => item.mime?.startsWith("image/"),
|
||||||
|
)
|
||||||
|
setItems(onlyImages)
|
||||||
|
|
||||||
|
if (themeData.theme) {
|
||||||
|
if (typeof themeData.theme.backgroundUrl === "string") {
|
||||||
|
setBackground(themeData.theme.backgroundUrl || null)
|
||||||
|
setCustomUrl(themeData.theme.backgroundUrl || "")
|
||||||
|
}
|
||||||
|
if (typeof themeData.theme.brandName === "string") {
|
||||||
|
setBrandName(themeData.theme.brandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setInitializing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const persistTheme = async (next: { backgroundUrl?: string | null; brandName?: string }) => {
|
||||||
|
setSavingTheme(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/theme", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(next),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || "Failed to save theme")
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to save theme"
|
||||||
|
setSaveError(message)
|
||||||
|
} finally {
|
||||||
|
setSavingTheme(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSet = (url: string) => {
|
||||||
|
if (!url) return
|
||||||
|
setBackground(url)
|
||||||
|
persistTheme({ backgroundUrl: url })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyCustom = () => {
|
||||||
|
if (!customUrl.trim()) return
|
||||||
|
handleSet(customUrl.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async (file?: File | null) => {
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
setUploadError(null)
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("file", file)
|
||||||
|
const res = await fetch("/api/media", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || "Upload failed")
|
||||||
|
}
|
||||||
|
if (data.media?.url) {
|
||||||
|
handleSet(data.media.url)
|
||||||
|
setCustomUrl(data.media.url)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Upload failed"
|
||||||
|
setUploadError(message)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
reset()
|
||||||
|
setCustomUrl("")
|
||||||
|
persistTheme({ backgroundUrl: null, brandName: "Rahoot" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={onBack} className="bg-gray-700">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Theme editor</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Set the game background from uploads or a custom URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Branding</h3>
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-gray-600">
|
||||||
|
Brand name
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={brandName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBrandName(e.target.value)
|
||||||
|
persistTheme({ brandName: e.target.value })
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="e.g., Kwalitaria Pub Quiz"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Appears in the browser title bar and elsewhere we surface the brand.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Preview</h3>
|
||||||
|
<div className="relative h-60 w-full overflow-hidden rounded-md border border-gray-100 bg-gray-50">
|
||||||
|
<Image
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Background preview"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-white">
|
||||||
|
<span className="rounded bg-black/50 px-3 py-1 text-sm font-semibold">
|
||||||
|
Current background
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="bg-gray-700" onClick={handleReset}>
|
||||||
|
Reset to default
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-primary" onClick={handleApplyCustom}>
|
||||||
|
Apply custom URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={customUrl}
|
||||||
|
onChange={(e) => setCustomUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/background.webp or /media/your-file"
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Paste any reachable image URL (including your uploaded media path). Changes apply immediately to the game background.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Images from media library</h3>
|
||||||
|
<p className="text-sm text-gray-500">Pick any uploaded image as the background.</p>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-gray-700" onClick={load} disabled={loading}>
|
||||||
|
{loading ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 p-3">
|
||||||
|
<label className="flex items-center gap-3 text-sm font-semibold text-gray-800">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="text-sm"
|
||||||
|
onChange={(e) => handleUpload(e.target.files?.[0])}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
{uploading ? "Uploading..." : "Upload image"}
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Upload a background image (webp/png/jpg). It will be available immediately and selected as the current background.
|
||||||
|
</p>
|
||||||
|
{uploadError && (
|
||||||
|
<p className="mt-1 text-xs font-semibold text-red-600">{uploadError}</p>
|
||||||
|
)}
|
||||||
|
{saveError && (
|
||||||
|
<p className="mt-1 text-xs font-semibold text-red-600">{saveError}</p>
|
||||||
|
)}
|
||||||
|
{(savingTheme || initializing) && !uploading && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{initializing ? "Loading theme…" : "Saving theme…"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.fileName}
|
||||||
|
className={clsx(
|
||||||
|
"relative h-32 overflow-hidden rounded-md border border-gray-200 bg-gray-50 text-left shadow-sm transition hover:border-primary",
|
||||||
|
backgroundUrl === item.url && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
onClick={() => handleSet(item.url)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.url}
|
||||||
|
alt={item.fileName}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="200px"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/0" />
|
||||||
|
<div className="absolute bottom-2 left-2 right-2 text-xs font-semibold text-white drop-shadow">
|
||||||
|
{item.fileName}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500">
|
||||||
|
No images uploaded yet. Upload an image in the Media page, then pick it here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeEditor
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
const BrandShell = ({ children }: PropsWithChildren) => (
|
||||||
|
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default BrandShell
|
||||||
@@ -3,6 +3,7 @@ import Form from "@rahoot/web/components/Form"
|
|||||||
import Input from "@rahoot/web/components/Input"
|
import Input from "@rahoot/web/components/Input"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
|
import BrandShell from "@rahoot/web/components/game/join/BrandShell"
|
||||||
import { KeyboardEvent, useState } from "react"
|
import { KeyboardEvent, useState } from "react"
|
||||||
|
|
||||||
const Room = () => {
|
const Room = () => {
|
||||||
@@ -25,14 +26,16 @@ const Room = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form>
|
<BrandShell>
|
||||||
|
<Form>
|
||||||
<Input
|
<Input
|
||||||
onChange={(e) => setInvitation(e.target.value)}
|
onChange={(e) => setInvitation(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="PIN Code here"
|
placeholder="PIN Code here"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleJoin}>Submit</Button>
|
<Button onClick={handleJoin}>Submit</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
</BrandShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { STATUS } from "@rahoot/common/types/game/status"
|
|||||||
import Button from "@rahoot/web/components/Button"
|
import Button from "@rahoot/web/components/Button"
|
||||||
import Form from "@rahoot/web/components/Form"
|
import Form from "@rahoot/web/components/Form"
|
||||||
import Input from "@rahoot/web/components/Input"
|
import Input from "@rahoot/web/components/Input"
|
||||||
|
import BrandShell from "@rahoot/web/components/game/join/BrandShell"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
|
|
||||||
@@ -42,14 +43,16 @@ const Username = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form>
|
<BrandShell>
|
||||||
<Input
|
<Form>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
<Input
|
||||||
onKeyDown={handleKeyDown}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Username here"
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
placeholder="Username here"
|
||||||
<Button onClick={handleLogin}>Submit</Button>
|
/>
|
||||||
</Form>
|
<Button onClick={handleLogin}>Submit</Button>
|
||||||
|
</Form>
|
||||||
|
</BrandShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Answers = ({
|
const Answers = ({
|
||||||
data: {
|
data: { question, answers, image, media, time, totalPlayer },
|
||||||
question,
|
|
||||||
answers,
|
|
||||||
image,
|
|
||||||
media,
|
|
||||||
time,
|
|
||||||
totalPlayer,
|
|
||||||
allowsMultiple,
|
|
||||||
},
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { gameId }: { gameId?: string } = useParams()
|
const { gameId }: { gameId?: string } = useParams()
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
@@ -39,8 +31,6 @@ const Answers = ({
|
|||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
const [totalAnswer, setTotalAnswer] = useState(0)
|
const [totalAnswer, setTotalAnswer] = useState(0)
|
||||||
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||||
const [selectedAnswers, setSelectedAnswers] = useState<number[]>([])
|
|
||||||
const [submitted, setSubmitted] = useState(false)
|
|
||||||
|
|
||||||
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
||||||
volume: 0.1,
|
volume: 0.1,
|
||||||
@@ -55,39 +45,20 @@ const Answers = ({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitAnswers = (keys: number[]) => {
|
const handleAnswer = (answerKey: number) => () => {
|
||||||
if (!player || submitted || keys.length === 0) {
|
if (!player) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socket?.emit("player:selectedAnswer", {
|
socket?.emit("player:selectedAnswer", {
|
||||||
gameId,
|
gameId,
|
||||||
data: {
|
data: {
|
||||||
answerKeys: keys,
|
answerKey,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setSubmitted(true)
|
|
||||||
sfxPop()
|
sfxPop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAnswer = (answerKey: number) => () => {
|
|
||||||
if (!player) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowsMultiple) {
|
|
||||||
setSelectedAnswers([answerKey])
|
|
||||||
submitAnswers([answerKey])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedAnswers((prev) =>
|
|
||||||
prev.includes(answerKey)
|
|
||||||
? prev.filter((key) => key !== answerKey)
|
|
||||||
: [...prev, answerKey],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
playMusic()
|
playMusic()
|
||||||
|
|
||||||
@@ -117,25 +88,12 @@ const Answers = ({
|
|||||||
sfxPop()
|
sfxPop()
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCooldown(time)
|
|
||||||
setPaused(false)
|
|
||||||
setSelectedAnswers([])
|
|
||||||
setSubmitted(false)
|
|
||||||
setTotalAnswer(0)
|
|
||||||
}, [question, time])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 flex-col justify-between">
|
<div className="flex h-full flex-1 flex-col justify-between">
|
||||||
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
||||||
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||||
{question}
|
{question}
|
||||||
</h2>
|
</h2>
|
||||||
{allowsMultiple && (
|
|
||||||
<p className="rounded-full bg-black/40 px-4 py-2 text-sm font-semibold text-white">
|
|
||||||
Select all correct answers, then submit.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<QuestionMedia
|
<QuestionMedia
|
||||||
media={media || (image ? { type: "image", url: image } : undefined)}
|
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||||
@@ -170,29 +128,11 @@ const Answers = ({
|
|||||||
className={clsx(ANSWERS_COLORS[key])}
|
className={clsx(ANSWERS_COLORS[key])}
|
||||||
icon={ANSWERS_ICONS[key]}
|
icon={ANSWERS_ICONS[key]}
|
||||||
onClick={handleAnswer(key)}
|
onClick={handleAnswer(key)}
|
||||||
disabled={submitted}
|
|
||||||
selected={selectedAnswers.includes(key)}
|
|
||||||
>
|
>
|
||||||
{answer}
|
{answer}
|
||||||
</AnswerButton>
|
</AnswerButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{allowsMultiple && (
|
|
||||||
<div className="mx-auto flex w-full max-w-7xl justify-end px-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submitAnswers(selectedAnswers)}
|
|
||||||
disabled={submitted || selectedAnswers.length === 0}
|
|
||||||
className={clsx(
|
|
||||||
"rounded bg-white/80 px-4 py-2 text-sm font-semibold text-slate-900 shadow-md transition hover:bg-white",
|
|
||||||
(submitted || selectedAnswers.length === 0) &&
|
|
||||||
"cursor-not-allowed opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{submitted ? "Submitted" : "Submit answers"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ const Responses = ({
|
|||||||
stopMusic()
|
stopMusic()
|
||||||
}, [playMusic, stopMusic])
|
}, [playMusic, stopMusic])
|
||||||
|
|
||||||
const correctSet = new Set(
|
|
||||||
Array.isArray(correct) ? correct : typeof correct === "number" ? [correct] : [],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 flex-col justify-between">
|
<div className="flex h-full flex-1 flex-col justify-between">
|
||||||
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
||||||
@@ -111,7 +107,7 @@ const Responses = ({
|
|||||||
<AnswerButton
|
<AnswerButton
|
||||||
key={key}
|
key={key}
|
||||||
className={clsx(ANSWERS_COLORS[key], {
|
className={clsx(ANSWERS_COLORS[key], {
|
||||||
"opacity-65": responses && !correctSet.has(key),
|
"opacity-65": responses && correct !== key,
|
||||||
})}
|
})}
|
||||||
icon={ANSWERS_ICONS[key]}
|
icon={ANSWERS_ICONS[key]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Config from "@rahoot/socket/services/config"
|
||||||
|
|
||||||
|
export type ThemeSettings = {
|
||||||
|
brandName: string
|
||||||
|
backgroundUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTheme = (): ThemeSettings => {
|
||||||
|
const theme = Config.theme()
|
||||||
|
return {
|
||||||
|
brandName: theme.brandName || "Rahoot",
|
||||||
|
backgroundUrl:
|
||||||
|
typeof theme.backgroundUrl === "string" && theme.backgroundUrl.length > 0
|
||||||
|
? theme.backgroundUrl
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveTheme = (payload: Partial<ThemeSettings>): ThemeSettings => {
|
||||||
|
const current = getTheme()
|
||||||
|
const merged = {
|
||||||
|
brandName: payload.brandName ?? current.brandName,
|
||||||
|
backgroundUrl:
|
||||||
|
payload.backgroundUrl === undefined
|
||||||
|
? current.backgroundUrl
|
||||||
|
: payload.backgroundUrl,
|
||||||
|
}
|
||||||
|
return Config.saveTheme(merged)
|
||||||
|
}
|
||||||
@@ -25,7 +25,16 @@ const initialState = {
|
|||||||
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
|
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
|
||||||
setGameId: (gameId) => set({ gameId }),
|
setGameId: (gameId) => {
|
||||||
|
try {
|
||||||
|
if (gameId) {
|
||||||
|
localStorage.setItem("last_manager_game_id", gameId)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("last_manager_game_id")
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
set({ gameId })
|
||||||
|
},
|
||||||
|
|
||||||
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
||||||
resetStatus: () => set({ status: null }),
|
resetStatus: () => set({ status: null }),
|
||||||
@@ -35,5 +44,10 @@ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
|
|||||||
players: typeof players === "function" ? players(state.players) : players,
|
players: typeof players === "function" ? players(state.players) : players,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
reset: () => set(initialState),
|
reset: () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("last_manager_game_id")
|
||||||
|
} catch {}
|
||||||
|
set(initialState)
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { create } from "zustand"
|
||||||
|
import { persist } from "zustand/middleware"
|
||||||
|
|
||||||
|
type ThemeState = {
|
||||||
|
backgroundUrl: string | null
|
||||||
|
brandName: string
|
||||||
|
setBackground: (_url: string | null) => void
|
||||||
|
setBrandName: (_name: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
backgroundUrl: null,
|
||||||
|
brandName: "Rahoot",
|
||||||
|
setBackground: (backgroundUrl) => set({ backgroundUrl }),
|
||||||
|
setBrandName: (brandName) => set({ brandName }),
|
||||||
|
reset: () => set({ backgroundUrl: null, brandName: "Rahoot" }),
|
||||||
|
}),
|
||||||
|
{ name: "theme-preferences" },
|
||||||
|
),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user