mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
adding theming to client and bug fixes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
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
|
||||||
@@ -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">
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user