adding theming to client and bug fixes

This commit is contained in:
RandyJC
2025-12-09 10:36:41 +01:00
parent f748d6ec3f
commit 6c16dd146a
6 changed files with 190 additions and 5 deletions

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

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

@@ -1,5 +1,6 @@
import Toaster from "@rahoot/web/components/Toaster" import Toaster from "@rahoot/web/components/Toaster"
import BrandingHelmet from "@rahoot/web/components/BrandingHelmet" 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"
@@ -21,6 +22,7 @@ const RootLayout = ({ children }: PropsWithChildren) => (
<body className={`${montserrat.variable} bg-secondary antialiased`}> <body className={`${montserrat.variable} bg-secondary antialiased`}>
<SocketProvider> <SocketProvider>
<BrandingHelmet /> <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,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

@@ -26,6 +26,9 @@ const ThemeEditor = ({ onBack }: Props) => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null) 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( const previewUrl = useMemo(
() => backgroundUrl || customUrl || background.src, () => backgroundUrl || customUrl || background.src,
@@ -35,17 +38,36 @@ const ThemeEditor = ({ onBack }: Props) => {
const load = async () => { const load = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await fetch("/api/media", { cache: "no-store" }) const [mediaRes, themeRes] = await Promise.all([
const data = await res.json() fetch("/api/media", { cache: "no-store" }),
if (!res.ok) throw new Error(data.error || "Failed to load media") fetch("/api/theme", { cache: "no-store" }),
const onlyImages = (data.media || []).filter( ])
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/"), (item: MediaItem) => item.mime?.startsWith("image/"),
) )
setItems(onlyImages) 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) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
setLoading(false) setLoading(false)
setInitializing(false)
} }
} }
@@ -53,9 +75,29 @@ const ThemeEditor = ({ onBack }: Props) => {
load() 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) => { const handleSet = (url: string) => {
if (!url) return if (!url) return
setBackground(url) setBackground(url)
persistTheme({ backgroundUrl: url })
} }
const handleApplyCustom = () => { const handleApplyCustom = () => {
@@ -94,6 +136,7 @@ const ThemeEditor = ({ onBack }: Props) => {
const handleReset = () => { const handleReset = () => {
reset() reset()
setCustomUrl("") setCustomUrl("")
persistTheme({ backgroundUrl: null, brandName: "Rahoot" })
} }
return ( return (
@@ -121,7 +164,10 @@ const ThemeEditor = ({ onBack }: Props) => {
</span> </span>
<input <input
value={brandName} value={brandName}
onChange={(e) => 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" className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
placeholder="e.g., Kwalitaria Pub Quiz" placeholder="e.g., Kwalitaria Pub Quiz"
/> />
@@ -196,6 +242,14 @@ const ThemeEditor = ({ onBack }: Props) => {
{uploadError && ( {uploadError && (
<p className="mt-1 text-xs font-semibold text-red-600">{uploadError}</p> <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>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">

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