Compare commits

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. - 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. - Media library view: see all uploads, where theyre used, and delete unused files.
- Delete quizzes from the editor. - Delete quizzes from the editor.
- Pause/Resume/Skip question intro and answer timers; End Game button to reset everyone. - Pause/Resume/Break/Skip question intro and answer timers; End Game button to reset everyone.
- Player list in manager view showing connected/disconnected players. - 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. - 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. - 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: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

View File

@@ -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))
) )

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}
/> />
) )
} }

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() 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

View File

@@ -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>

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" 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>
) )

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 { 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&apos;ll resume from the same spot.</p>
</div>
</div>
)}
</> </>
)} )}
</section> </section>

View File

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

View File

@@ -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"}

View File

@@ -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"

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

View File

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

View File

@@ -24,6 +24,7 @@ const Responses = ({
const [percentages, setPercentages] = useState<Record<string, string>>({}) const [percentages, setPercentages] = useState<Record<string, string>>({})
const [isMusicPlaying, setIsMusicPlaying] = useState(false) const [isMusicPlaying, setIsMusicPlaying] = useState(false)
const [isMediaPlaying, setIsMediaPlaying] = useState(false) const [isMediaPlaying, setIsMediaPlaying] = useState(false)
const correctSet = Array.isArray(correct) ? correct : [correct]
const [sfxResults] = useSound(SFX_RESULTS_SOUND, { const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2, volume: 0.2,
@@ -81,39 +82,87 @@ const Responses = ({
/> />
<div <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)` }} style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
> >
{answers.map((_, key) => ( {answers.map((label, key) => {
<div const votes = responses[key] || 0
key={key} const percent = percentages[key] || "0%"
className={clsx( const isCorrect = correctSet.includes(key)
"flex flex-col justify-end self-end overflow-hidden rounded-md",
ANSWERS_COLORS[key], return (
)} <div
style={{ height: percentages[key] }} key={key}
> className={clsx(
<span className="w-full bg-black/10 text-center text-lg font-bold text-white drop-shadow-md"> "relative flex flex-col justify-end self-end overflow-hidden rounded-md border shadow",
{responses[key] || 0} isCorrect ? "border-green-400 ring-4 ring-green-300/50" : "border-white/40",
</span> ANSWERS_COLORS[key],
</div> )}
))} 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>
<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"> <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) => ( {answers.map((answer, key) => {
<AnswerButton const votes = responses[key] || 0
key={key} const totalVotes = Object.values(responses).reduce(
className={clsx(ANSWERS_COLORS[key], { (acc, val) => acc + (val || 0),
"opacity-65": responses && correct !== key, 0,
})} )
icon={ANSWERS_ICONS[key]} const percent = totalVotes ? Math.round((votes / totalVotes) * 100) : 0
> const isCorrect = correctSet.includes(key)
{answer}
</AnswerButton> 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> </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) => ({ 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)
},
})) }))

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