mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
Compare commits
23 Commits
497dd2ea4c
...
session-ma
| 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 |
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -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
|
||||||
@@ -137,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)
|
||||||
@@ -193,6 +196,7 @@ class Game {
|
|||||||
paused: this.cooldown.paused,
|
paused: this.cooldown.paused,
|
||||||
remaining: this.cooldown.remaining,
|
remaining: this.cooldown.remaining,
|
||||||
},
|
},
|
||||||
|
breakActive: this.breakActive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,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}`)
|
||||||
@@ -361,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}`
|
||||||
)
|
)
|
||||||
@@ -453,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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
30
packages/web/src/app/api/theme/route.ts
Normal file
30
packages/web/src/app/api/theme/route.ts
Normal file
@@ -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>
|
||||||
|
|||||||
33
packages/web/src/components/BrandHeading.tsx
Normal file
33
packages/web/src/components/BrandHeading.tsx
Normal file
@@ -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
|
||||||
18
packages/web/src/components/BrandingHelmet.tsx
Normal file
18
packages/web/src/components/BrandingHelmet.tsx
Normal file
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
32
packages/web/src/components/ThemeHydrator.tsx
Normal file
32
packages/web/src/components/ThemeHydrator.tsx
Normal file
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -460,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"}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
291
packages/web/src/components/game/create/ThemeEditor.tsx
Normal file
291
packages/web/src/components/game/create/ThemeEditor.tsx
Normal file
@@ -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
|
||||||
11
packages/web/src/components/game/join/BrandShell.tsx
Normal file
11
packages/web/src/components/game/join/BrandShell.tsx
Normal file
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
packages/web/src/server/theme.ts
Normal file
29
packages/web/src/server/theme.ts
Normal file
@@ -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)
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
23
packages/web/src/stores/theme.tsx
Normal file
23
packages/web/src/stores/theme.tsx
Normal file
@@ -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