mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
Compare commits
25 Commits
497dd2ea4c
...
2c284985fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c284985fa | ||
|
|
ae37df6643 | ||
|
|
8a25192034 | ||
|
|
84b269d2d1 | ||
|
|
760fc93c1b | ||
|
|
ab7ddfed4b | ||
|
|
3ac9d5ac39 | ||
|
|
a4fa783ed2 | ||
|
|
6b1b976790 | ||
|
|
fb2da91394 | ||
|
|
a23299a45e | ||
|
|
13c1e9c3f6 | ||
|
|
e4342bbdac | ||
|
|
61b47fa5d5 | ||
|
|
efbea1801c | ||
|
|
dba5024207 | ||
|
|
684a6a3c12 | ||
|
|
9d64f7f0b4 | ||
|
|
1679e68691 | ||
|
|
6c16dd146a | ||
|
|
f748d6ec3f | ||
|
|
a572eb35cd | ||
|
|
7bfb138f99 | ||
|
|
9a9ad640c0 | ||
|
|
3d247513ce |
@@ -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 they’re 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
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"
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
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 { 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'll resume from the same spot.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
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) => ({
|
||||
...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)
|
||||
},
|
||||
}))
|
||||
|
||||
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