diff --git a/packages/socket/src/services/config.ts b/packages/socket/src/services/config.ts
index af66a88..77d0bb5 100644
--- a/packages/socket/src/services/config.ts
+++ b/packages/socket/src/services/config.ts
@@ -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
diff --git a/packages/web/src/app/api/theme/route.ts b/packages/web/src/app/api/theme/route.ts
new file mode 100644
index 0000000..49b5896
--- /dev/null
+++ b/packages/web/src/app/api/theme/route.ts
@@ -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 })
+ }
+}
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index f848ef3..5cda174 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -1,5 +1,6 @@
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"
@@ -21,6 +22,7 @@ const RootLayout = ({ children }: PropsWithChildren) => (
+
{children}
diff --git a/packages/web/src/components/ThemeHydrator.tsx b/packages/web/src/components/ThemeHydrator.tsx
new file mode 100644
index 0000000..c308140
--- /dev/null
+++ b/packages/web/src/components/ThemeHydrator.tsx
@@ -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
diff --git a/packages/web/src/components/game/create/ThemeEditor.tsx b/packages/web/src/components/game/create/ThemeEditor.tsx
index 45f9cfc..0fe31af 100644
--- a/packages/web/src/components/game/create/ThemeEditor.tsx
+++ b/packages/web/src/components/game/create/ThemeEditor.tsx
@@ -26,6 +26,9 @@ const ThemeEditor = ({ onBack }: Props) => {
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState(null)
+ const [savingTheme, setSavingTheme] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const [initializing, setInitializing] = useState(true)
const previewUrl = useMemo(
() => backgroundUrl || customUrl || background.src,
@@ -35,17 +38,36 @@ const ThemeEditor = ({ onBack }: Props) => {
const load = async () => {
setLoading(true)
try {
- const res = await fetch("/api/media", { cache: "no-store" })
- const data = await res.json()
- if (!res.ok) throw new Error(data.error || "Failed to load media")
- const onlyImages = (data.media || []).filter(
+ 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)
}
}
@@ -53,9 +75,29 @@ const ThemeEditor = ({ onBack }: Props) => {
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 = () => {
@@ -94,6 +136,7 @@ const ThemeEditor = ({ onBack }: Props) => {
const handleReset = () => {
reset()
setCustomUrl("")
+ persistTheme({ backgroundUrl: null, brandName: "Rahoot" })
}
return (
@@ -121,7 +164,10 @@ const ThemeEditor = ({ onBack }: Props) => {
setBrandName(e.target.value)}
+ 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"
/>
@@ -196,6 +242,14 @@ const ThemeEditor = ({ onBack }: Props) => {
{uploadError && (
{uploadError}
)}
+ {saveError && (
+ {saveError}
+ )}
+ {(savingTheme || initializing) && !uploading && (
+
+ {initializing ? "Loading theme…" : "Saving theme…"}
+
+ )}
diff --git a/packages/web/src/server/theme.ts b/packages/web/src/server/theme.ts
new file mode 100644
index 0000000..fc39bfa
--- /dev/null
+++ b/packages/web/src/server/theme.ts
@@ -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 => {
+ const current = getTheme()
+ const merged = {
+ brandName: payload.brandName ?? current.brandName,
+ backgroundUrl:
+ payload.backgroundUrl === undefined
+ ? current.backgroundUrl
+ : payload.backgroundUrl,
+ }
+ return Config.saveTheme(merged)
+}