testing theme editor

This commit is contained in:
RandyJC
2025-12-09 09:55:41 +01:00
parent 497dd2ea4c
commit 3d247513ce
5 changed files with 223 additions and 8 deletions

View File

@@ -5,6 +5,7 @@ import { STATUS } from "@rahoot/common/types/game/status"
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword" import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor" import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary" 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 SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerStore } from "@rahoot/web/stores/manager" import { useManagerStore } from "@rahoot/web/stores/manager"
@@ -20,6 +21,7 @@ const Manager = () => {
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([]) const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
const [showEditor, setShowEditor] = useState(false) const [showEditor, setShowEditor] = useState(false)
const [showMedia, setShowMedia] = useState(false) const [showMedia, setShowMedia] = useState(false)
const [showTheme, setShowTheme] = useState(false)
useEvent("manager:quizzList", (quizzList) => { useEvent("manager:quizzList", (quizzList) => {
setIsAuth(true) setIsAuth(true)
@@ -69,12 +71,17 @@ const Manager = () => {
) )
} }
if (showTheme) {
return <ThemeEditor onBack={() => setShowTheme(false)} />
}
return ( return (
<SelectQuizz <SelectQuizz
quizzList={quizzList} quizzList={quizzList}
onSelect={handleCreate} onSelect={handleCreate}
onManage={() => setShowEditor(true)} onManage={() => setShowEditor(true)}
onMedia={() => setShowMedia(true)} onMedia={() => setShowMedia(true)}
onTheme={() => setShowTheme(true)}
/> />
) )
} }

View File

@@ -7,9 +7,9 @@ import Loader from "@rahoot/web/components/Loader"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player" import { usePlayerStore } from "@rahoot/web/stores/player"
import { useQuestionStore } from "@rahoot/web/stores/question" import { useQuestionStore } from "@rahoot/web/stores/question"
import { useThemeStore } from "@rahoot/web/stores/theme"
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants" import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
import clsx from "clsx" import clsx from "clsx"
import Image from "next/image"
import { PropsWithChildren, useEffect, useState } from "react" import { PropsWithChildren, useEffect, useState } from "react"
type Props = PropsWithChildren & { type Props = PropsWithChildren & {
@@ -37,6 +37,7 @@ const GameWrapper = ({
const { isConnected } = useSocket() const { isConnected } = useSocket()
const { player } = usePlayerStore() const { player } = usePlayerStore()
const { questionStates, setQuestionStates } = useQuestionStore() const { questionStates, setQuestionStates } = useQuestionStore()
const { backgroundUrl } = useThemeStore()
const [isDisabled, setIsDisabled] = useState(false) const [isDisabled, setIsDisabled] = useState(false)
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
@@ -56,14 +57,21 @@ const GameWrapper = ({
onNext?.() onNext?.()
} }
const resolvedBackground = backgroundUrl || background.src
return ( return (
<section className="relative flex min-h-screen w-full flex-col justify-between"> <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"> <div
<Image className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70"
className="pointer-events-none h-full w-full object-cover opacity-60" style={{
src={background} backgroundImage: `url(${resolvedBackground})`,
alt="background" backgroundSize: "cover",
/> backgroundPosition: "center",
backgroundRepeat: "no-repeat",
opacity: 0.7,
}}
>
<div className="h-full w-full bg-black/10" />
</div> </div>
{!isConnected && !statusName ? ( {!isConnected && !statusName ? (

View File

@@ -9,9 +9,16 @@ type Props = {
onSelect: (_id: string) => void onSelect: (_id: string) => void
onManage?: () => void onManage?: () => void
onMedia?: () => void onMedia?: () => void
onTheme?: () => void
} }
const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => { const SelectQuizz = ({
quizzList,
onSelect,
onManage,
onMedia,
onTheme,
}: Props) => {
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const handleSelect = (id: string) => () => { const handleSelect = (id: string) => () => {
@@ -45,6 +52,14 @@ const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
Media Media
</button> </button>
)} )}
{onTheme && (
<button
className="text-sm font-semibold text-amber-700 underline"
onClick={onTheme}
>
Theme
</button>
)}
{onManage && ( {onManage && (
<button <button
className="text-sm font-semibold text-primary underline" className="text-sm font-semibold text-primary underline"

View File

@@ -0,0 +1,166 @@
"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, setBackground, reset } = useThemeStore()
const [customUrl, setCustomUrl] = useState("")
const [items, setItems] = useState<MediaItem[]>([])
const [loading, setLoading] = useState(false)
const previewUrl = useMemo(
() => backgroundUrl || customUrl || background.src,
[backgroundUrl, customUrl],
)
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(
(item: MediaItem) => item.mime?.startsWith("image/"),
)
setItems(onlyImages)
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const handleSet = (url: string) => {
if (!url) return
setBackground(url)
}
const handleApplyCustom = () => {
if (!customUrl.trim()) return
handleSet(customUrl.trim())
}
const handleReset = () => {
reset()
setCustomUrl("")
}
return (
<div className="flex w-full flex-col gap-4">
<div className="flex items-center gap-2">
<button
onClick={onBack}
className="rounded-md bg-gray-700 px-3 py-2 text-white"
>
Back
</button>
<h2 className="text-xl font-semibold">Theme editor</h2>
</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">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="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

View File

@@ -0,0 +1,19 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
type ThemeState = {
backgroundUrl: string | null
setBackground: (_url: string | null) => void
reset: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
backgroundUrl: null,
setBackground: (backgroundUrl) => set({ backgroundUrl }),
reset: () => set({ backgroundUrl: null }),
}),
{ name: "theme-preferences" },
),
)