25 Commits

Author SHA1 Message Date
RandyJC
2c284985fa adding new correct answer visuals 2025-12-09 14:48:45 +01:00
RandyJC
ae37df6643 update README 2025-12-09 14:36:49 +01:00
RandyJC
8a25192034 clean resume state when pausing game 2025-12-09 14:25:21 +01:00
RandyJC
84b269d2d1 adding resume game in manager page 2025-12-09 14:18:57 +01:00
RandyJC
760fc93c1b build error 2025-12-09 14:08:49 +01:00
RandyJC
ab7ddfed4b adding pause and resume in game state 2025-12-09 14:02:52 +01:00
RandyJC
3ac9d5ac39 fix width 2025-12-09 12:58:59 +01:00
RandyJC
a4fa783ed2 fix 2025-12-09 12:32:15 +01:00
RandyJC
6b1b976790 fix width 2025-12-09 12:23:00 +01:00
RandyJC
fb2da91394 fix width 2025-12-09 12:19:44 +01:00
RandyJC
a23299a45e fix 2025-12-09 12:07:39 +01:00
RandyJC
13c1e9c3f6 fix 2025-12-09 12:03:56 +01:00
RandyJC
e4342bbdac fix 2025-12-09 12:00:28 +01:00
RandyJC
61b47fa5d5 fix 2025-12-09 11:57:43 +01:00
RandyJC
efbea1801c logo alignment fix 2025-12-09 11:54:53 +01:00
RandyJC
dba5024207 fix build error 2025-12-09 11:50:43 +01:00
RandyJC
684a6a3c12 fix branding 2025-12-09 11:48:05 +01:00
RandyJC
9d64f7f0b4 fix branding 2025-12-09 11:44:11 +01:00
RandyJC
1679e68691 bug fixing 2025-12-09 10:43:28 +01:00
RandyJC
6c16dd146a adding theming to client and bug fixes 2025-12-09 10:36:41 +01:00
RandyJC
f748d6ec3f adding option for branding 2025-12-09 10:29:18 +01:00
RandyJC
a572eb35cd polish UI to all menus 2025-12-09 10:23:46 +01:00
RandyJC
7bfb138f99 polish button in theme editor 2025-12-09 10:17:53 +01:00
RandyJC
9a9ad640c0 adding option to upload from within theme editor 2025-12-09 10:00:14 +01:00
RandyJC
3d247513ce testing theme editor 2025-12-09 09:55:41 +01:00
26 changed files with 879 additions and 101 deletions

View File

@@ -161,8 +161,8 @@ Tip: You can now create and edit quizzes directly from the Manager UI (login at
- Manual “Set timing from media” to align cooldown/answer time with clip length.
- Media library view: see all uploads, where theyre used, and delete unused files.
- Delete quizzes from the editor.
- Pause/Resume/Skip question intro and answer timers; End Game button to reset everyone.
- Player list in manager view showing connected/disconnected players.
- Pause/Resume/Break/Skip question intro and answer timers; End Game button to reset everyone.
- Player list in manager view showing connected/disconnected players (persists across reconnects); resume a game from where it left off.
- Click-to-zoom images during questions.
- Player reconnect resilience: Redis snapshotting keeps game state; clients auto-rejoin using stored `clientId`/last game; username/points are hydrated locally after refresh without a manual reload.

View File

@@ -35,6 +35,7 @@ export interface ServerToClientEvents {
"game:reset": (_message: string) => void
"game:updateQuestion": (_data: { current: number; total: number }) => void
"game:playerAnswer": (_count: number) => void
"game:break": (_active: boolean) => void
// Player events
"player:successReconnect": (_data: {
@@ -66,6 +67,7 @@ export interface ServerToClientEvents {
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
"manager:quizzSaved": (_quizz: QuizzWithId) => void
"manager:quizzDeleted": (_id: string) => void
"manager:break": (_active: boolean) => void
}
export interface ClientToServerEvents {
@@ -78,6 +80,7 @@ export interface ClientToServerEvents {
"manager:abortQuiz": (_message: MessageGameId) => void
"manager:pauseCooldown": (_message: MessageGameId) => void
"manager:resumeCooldown": (_message: MessageGameId) => void
"manager:setBreak": (_message: { gameId?: string; active: boolean }) => void
"manager:endGame": (_message: MessageGameId) => void
"manager:skipQuestionIntro": (_message: MessageGameId) => void
"manager:nextQuestion": (_message: MessageGameId) => void

View File

@@ -234,6 +234,10 @@ io.on("connection", (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 }) =>
withGame(gameId, socket, (game) => game.endGame(socket, registry))
)

View File

@@ -37,6 +37,22 @@ class Config {
if (!isMediaExists) {
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() {
@@ -151,6 +167,17 @@ class Config {
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() {
this.ensureBaseFolders()
@@ -231,6 +258,17 @@ class Config {
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

View File

@@ -46,6 +46,7 @@ class Game {
timer: NodeJS.Timeout | null
resolve: (() => void) | null
}
breakActive: boolean
constructor(io: Server, socket: Socket, quizz: Quizz) {
if (!io) {
@@ -84,6 +85,7 @@ class Game {
timer: null,
resolve: null,
}
this.breakActive = false
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
@@ -137,6 +139,7 @@ class Game {
timer: null,
resolve: null,
}
game.breakActive = snapshot.breakActive || false
if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) {
game.startCooldown(game.cooldown.remaining)
@@ -193,6 +196,7 @@ class Game {
paused: this.cooldown.paused,
remaining: this.cooldown.remaining,
},
breakActive: this.breakActive,
}
}
@@ -310,6 +314,10 @@ class Game {
players: this.players,
})
socket.emit("game:totalPlayers", this.players.length)
if (this.breakActive) {
socket.emit("manager:break", true)
socket.emit("game:break", true)
}
registry.reactivateGame(this.gameId)
console.log(`Manager reconnected to game ${this.inviteCode}`)
@@ -361,6 +369,9 @@ class Game {
},
})
socket.emit("game:totalPlayers", this.players.length)
if (this.breakActive) {
socket.emit("game:break", true)
}
console.log(
`Player ${player.username} reconnected to game ${this.inviteCode}`
)
@@ -453,6 +464,27 @@ class Game {
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) {
if (this.manager.id !== socket.id) {
return

View File

@@ -1,9 +1,8 @@
"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 { useSocket } from "@rahoot/web/contexts/socketProvider"
import Image from "next/image"
import { PropsWithChildren, useEffect } from "react"
const AuthLayout = ({ children }: PropsWithChildren) => {
@@ -14,34 +13,34 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
}
}, [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) {
return (
<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>
<Shell>
<div className="flex flex-col items-center gap-3">
<Loader className="h-23" />
<h2 className="text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
Loading...
</h2>
</div>
<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>
</Shell>
)
}
return (
<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>
)
return <Shell>{children}</Shell>
}
export default AuthLayout

View File

@@ -5,14 +5,16 @@ import { STATUS } from "@rahoot/common/types/game/status"
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
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 Button from "@rahoot/web/components/Button"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerStore } from "@rahoot/web/stores/manager"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useEffect, useState } from "react"
const Manager = () => {
const { setGameId, setStatus } = useManagerStore()
const { gameId, setGameId, setStatus } = useManagerStore()
const router = useRouter()
const { socket } = useSocket()
@@ -20,6 +22,8 @@ const Manager = () => {
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
const [showEditor, setShowEditor] = useState(false)
const [showMedia, setShowMedia] = useState(false)
const [showTheme, setShowTheme] = useState(false)
const [resumeGameId, setResumeGameId] = useState<string | null>(null)
useEvent("manager:quizzList", (quizzList) => {
setIsAuth(true)
@@ -29,15 +33,44 @@ const Manager = () => {
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
setGameId(gameId)
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
setResumeGameId(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) => {
socket?.emit("manager:auth", password)
}
const handleCreate = (quizzId: string) => {
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) {
return <ManagerPassword onSubmit={handleAuth} />
@@ -49,32 +82,42 @@ const Manager = () => {
quizzList={quizzList}
onBack={() => setShowEditor(false)}
onListUpdate={setQuizzList}
onBreakToggle={handleBreakToggle}
gameId={gameId}
/>
)
}
if (showMedia) {
return (
<div className="flex w-full flex-col gap-4">
<div className="flex gap-2">
<button
onClick={() => setShowMedia(false)}
className="rounded-md bg-gray-700 px-3 py-2 text-white"
>
<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 gap-2">
<Button onClick={() => setShowMedia(false)} className="bg-gray-700">
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>
<MediaLibrary />
</div>
)
}
if (showTheme) {
return <ThemeEditor onBack={() => setShowTheme(false)} />
}
return (
<SelectQuizz
quizzList={quizzList}
onSelect={handleCreate}
onManage={() => setShowEditor(true)}
onMedia={() => setShowMedia(true)}
onTheme={() => setShowTheme(true)}
resumeGameId={resumeGameId}
onResume={handleResume}
/>
)
}

View 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 })
}
}

View File

@@ -26,6 +26,7 @@ const ManagerGame = () => {
useManagerStore()
const { setQuestionStates } = useQuestionStore()
const [cooldownPaused, setCooldownPaused] = useState(false)
const [breakActive, setBreakActive] = useState(false)
const { players } = useManagerStore()
useEvent("game:status", ({ name, data }) => {
@@ -73,6 +74,9 @@ const ManagerGame = () => {
setCooldownPaused(isPaused)
})
useEvent("game:break", (active) => setBreakActive(active))
useEvent("manager:break", (active) => setBreakActive(active))
const handleSkip = () => {
if (!gameId) {
return
@@ -115,6 +119,11 @@ const ManagerGame = () => {
}
}
const handleBreakToggle = () => {
if (!gameId) return
socket?.emit("manager:setBreak", { gameId, active: !breakActive })
}
const handleEndGame = () => {
if (!gameId) return
socket?.emit("manager:endGame", { gameId })
@@ -173,6 +182,8 @@ const ManagerGame = () => {
showPause={
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
}
onBreakToggle={handleBreakToggle}
breakActive={breakActive}
onEnd={handleEndGame}
players={players}
manager

View File

@@ -1,24 +1,32 @@
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 type { Metadata } from "next"
import { Montserrat } from "next/font/google"
import { PropsWithChildren } from "react"
import "./globals.css"
import { getTheme } from "@rahoot/web/server/theme"
const montserrat = Montserrat({
variable: "--font-montserrat",
subsets: ["latin"],
})
export const metadata: Metadata = {
title: "Rahoot !",
icons: "/icon.svg",
export async function generateMetadata(): Promise<Metadata> {
const theme = getTheme()
return {
title: theme.brandName || "Rahoot",
icons: "/icon.svg",
}
}
const RootLayout = ({ children }: PropsWithChildren) => (
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
<body className={`${montserrat.variable} bg-secondary antialiased`}>
<SocketProvider>
<BrandingHelmet />
<ThemeHydrator />
<main className="text-base-[8px] flex flex-col">{children}</main>
<Toaster />
</SocketProvider>

View 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

View 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

View File

@@ -1,7 +1,7 @@
import { PropsWithChildren } from "react"
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}
</div>
)

View 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

View File

@@ -7,9 +7,9 @@ import Loader from "@rahoot/web/components/Loader"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { useQuestionStore } from "@rahoot/web/stores/question"
import { useThemeStore } from "@rahoot/web/stores/theme"
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
import clsx from "clsx"
import Image from "next/image"
import { PropsWithChildren, useEffect, useState } from "react"
type Props = PropsWithChildren & {
@@ -21,6 +21,8 @@ type Props = PropsWithChildren & {
onEnd?: () => void
players?: { id: string; username: string; connected: boolean }[]
manager?: boolean
onBreakToggle?: () => void
breakActive?: boolean
}
const GameWrapper = ({
@@ -33,11 +35,15 @@ const GameWrapper = ({
onEnd,
players,
manager,
onBreakToggle,
breakActive,
}: Props) => {
const { isConnected } = useSocket()
const { player } = usePlayerStore()
const { questionStates, setQuestionStates } = useQuestionStore()
const { backgroundUrl, setBackground, setBrandName } = useThemeStore()
const [isDisabled, setIsDisabled] = useState(false)
const [onBreak, setOnBreak] = useState(false)
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
useEvent("game:updateQuestion", ({ current, total }) => {
@@ -51,19 +57,50 @@ const GameWrapper = ({
setIsDisabled(false)
}, [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 = () => {
setIsDisabled(true)
onNext?.()
}
const resolvedBackground = backgroundUrl || background.src
return (
<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">
<Image
className="pointer-events-none h-full w-full object-cover opacity-60"
src={background}
alt="background"
/>
<div
className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70"
style={{
backgroundImage: `url(${resolvedBackground})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
opacity: 0.7,
}}
>
<div className="h-full w-full bg-black/10" />
</div>
{!isConnected && !statusName ? (
@@ -102,6 +139,17 @@ const GameWrapper = ({
</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 && (
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
End game
@@ -142,6 +190,15 @@ const GameWrapper = ({
</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&apos;ll resume from the same spot.</p>
</div>
</div>
)}
</>
)}
</section>

View File

@@ -27,7 +27,8 @@ const ManagerPassword = ({ onSubmit }: Props) => {
})
return (
<Form>
<div className="w-full max-w-xl">
<Form>
<Input
type="password"
onChange={(e) => setPassword(e.target.value)}
@@ -35,7 +36,8 @@ const ManagerPassword = ({ onSubmit }: Props) => {
placeholder="Manager password"
/>
<Button onClick={handleSubmit}>Submit</Button>
</Form>
</Form>
</div>
)
}

View File

@@ -12,6 +12,8 @@ type Props = {
quizzList: QuizzWithId[]
onBack: () => void
onListUpdate: (_quizz: QuizzWithId[]) => void
onBreakToggle?: (_active: boolean) => void
gameId?: string | null
}
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]}`
}
const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
const QuizEditor = ({
quizzList,
onBack,
onListUpdate,
onBreakToggle,
gameId,
}: Props) => {
const { socket } = useSocket()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<QuizzWithId | null>(null)
@@ -460,21 +468,31 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
}, [quizzList, selectedId])
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 gap-2">
<Button onClick={onBack} className="bg-gray-700">
Back
<div className="flex items-center gap-2">
<Button onClick={onBack} className="bg-gray-700">
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 onClick={handleNew} className="bg-blue-600">
New quiz
</Button>
{selectedId && (
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
Delete quiz
)}
{onBreakToggle && gameId && (
<>
<Button className="bg-amber-500" onClick={() => onBreakToggle(true)}>
Break
</Button>
)}
</div>
<Button className="bg-green-600" onClick={() => onBreakToggle(false)}>
Resume
</Button>
</>
)}
</div>
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? "Saving..." : "Save quiz"}

View File

@@ -9,9 +9,20 @@ type Props = {
onSelect: (_id: string) => void
onManage?: () => 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 handleSelect = (id: string) => () => {
@@ -37,6 +48,14 @@ const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Select a quizz</h1>
<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 && (
<button
className="text-sm font-semibold text-gray-700 underline"
@@ -45,6 +64,14 @@ const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
Media
</button>
)}
{onTheme && (
<button
className="text-sm font-semibold text-amber-700 underline"
onClick={onTheme}
>
Theme
</button>
)}
{onManage && (
<button
className="text-sm font-semibold text-primary underline"

View 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

View 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

View File

@@ -3,6 +3,7 @@ import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import BrandShell from "@rahoot/web/components/game/join/BrandShell"
import { KeyboardEvent, useState } from "react"
const Room = () => {
@@ -25,14 +26,16 @@ const Room = () => {
})
return (
<Form>
<BrandShell>
<Form>
<Input
onChange={(e) => setInvitation(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="PIN Code here"
/>
<Button onClick={handleJoin}>Submit</Button>
</Form>
</Form>
</BrandShell>
)
}

View File

@@ -4,6 +4,7 @@ import { STATUS } from "@rahoot/common/types/game/status"
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
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 { usePlayerStore } from "@rahoot/web/stores/player"
@@ -42,14 +43,16 @@ const Username = () => {
})
return (
<Form>
<Input
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Username here"
/>
<Button onClick={handleLogin}>Submit</Button>
</Form>
<BrandShell>
<Form>
<Input
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Username here"
/>
<Button onClick={handleLogin}>Submit</Button>
</Form>
</BrandShell>
)
}

View File

@@ -24,6 +24,7 @@ const Responses = ({
const [percentages, setPercentages] = useState<Record<string, string>>({})
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
const correctSet = Array.isArray(correct) ? correct : [correct]
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2,
@@ -81,39 +82,87 @@ const Responses = ({
/>
<div
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
className={`mt-8 grid h-48 w-full max-w-5xl gap-4 px-2`}
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
>
{answers.map((_, key) => (
<div
key={key}
className={clsx(
"flex flex-col justify-end self-end overflow-hidden rounded-md",
ANSWERS_COLORS[key],
)}
style={{ height: percentages[key] }}
>
<span className="w-full bg-black/10 text-center text-lg font-bold text-white drop-shadow-md">
{responses[key] || 0}
</span>
</div>
))}
{answers.map((label, key) => {
const votes = responses[key] || 0
const percent = percentages[key] || "0%"
const isCorrect = correctSet.includes(key)
return (
<div
key={key}
className={clsx(
"relative flex flex-col justify-end self-end overflow-hidden rounded-md border shadow",
isCorrect ? "border-green-400 ring-4 ring-green-300/50" : "border-white/40",
ANSWERS_COLORS[key],
)}
style={{ height: percent }}
title={label}
>
<div className="absolute inset-0 bg-black/20" />
<div className="relative flex w-full flex-col items-center gap-1 bg-black/25 px-2 py-2 text-white">
<span className="text-sm font-semibold">{label}</span>
<span className="text-lg font-bold">{votes} ({percent})</span>
<span
className={clsx(
"rounded-full px-3 py-0.5 text-xs font-bold uppercase",
isCorrect ? "bg-green-500 text-white" : "bg-black/40 text-white",
)}
>
{isCorrect ? "Correct" : "Not correct"}
</span>
</div>
</div>
)
})}
</div>
</div>
<div>
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
{answers.map((answer, key) => (
<AnswerButton
key={key}
className={clsx(ANSWERS_COLORS[key], {
"opacity-65": responses && correct !== key,
})}
icon={ANSWERS_ICONS[key]}
>
{answer}
</AnswerButton>
))}
<div className="mx-auto mb-6 grid w-full max-w-7xl grid-cols-2 gap-2 px-2 text-lg font-bold text-white md:text-xl">
{answers.map((answer, key) => {
const votes = responses[key] || 0
const totalVotes = Object.values(responses).reduce(
(acc, val) => acc + (val || 0),
0,
)
const percent = totalVotes ? Math.round((votes / totalVotes) * 100) : 0
const isCorrect = correctSet.includes(key)
return (
<div key={key} className="flex flex-col gap-2 rounded-md bg-white/10 p-2 shadow">
<AnswerButton
className={clsx(
ANSWERS_COLORS[key],
"w-full justify-between",
!isCorrect && "opacity-70",
)}
icon={ANSWERS_ICONS[key]}
>
<span>{answer}</span>
<span
className={clsx(
"rounded-full px-3 py-0.5 text-sm font-bold",
isCorrect ? "bg-green-500 text-white" : "bg-black/30 text-white",
)}
>
{isCorrect ? "Correct" : "Wrong"}
</span>
</AnswerButton>
<div className="flex items-center justify-between text-sm text-gray-100">
<span>
Votes: <strong>{votes}</strong>
</span>
<span>
{percent}
%
</span>
</div>
</div>
)
})}
</div>
</div>
</div>

View 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)
}

View File

@@ -25,7 +25,16 @@ const initialState = {
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
...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) }),
resetStatus: () => set({ status: null }),
@@ -35,5 +44,10 @@ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
players: typeof players === "function" ? players(state.players) : players,
})),
reset: () => set(initialState),
reset: () => {
try {
localStorage.removeItem("last_manager_game_id")
} catch {}
set(initialState)
},
}))

View 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" },
),
)