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