stiff
This commit is contained in:
parent
6c32a26fc4
commit
1b9bab71be
35 changed files with 1704 additions and 240 deletions
56
admin/package-lock.json
generated
56
admin/package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
|||
"name": "sto-k-odnomu-admin",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
|
|
@ -557,6 +560,59 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@
|
|||
"@tanstack/react-query": "^5.90.11",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFeatureFlags } from '@/hooks/useFeatureFlags'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import DashboardPage from '@/pages/DashboardPage'
|
||||
import PacksPage from '@/pages/PacksPage'
|
||||
import UsersPage from '@/pages/UsersPage'
|
||||
import ThemesPage from '@/pages/ThemesPage'
|
||||
import RoomsPage from '@/pages/RoomsPage'
|
||||
import FeatureFlagsPage from '@/pages/FeatureFlagsPage'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { isThemesEnabled } = useFeatureFlags()
|
||||
|
||||
return (
|
||||
<TokenRefreshProvider>
|
||||
|
|
@ -29,8 +32,9 @@ function App() {
|
|||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/packs" element={<PacksPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/themes" element={<ThemesPage />} />
|
||||
{isThemesEnabled() && <Route path="/themes" element={<ThemesPage />} />}
|
||||
<Route path="/rooms" element={<RoomsPage />} />
|
||||
<Route path="/settings" element={<FeatureFlagsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
|
|
|
|||
106
admin/src/api/featureFlags.ts
Normal file
106
admin/src/api/featureFlags.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface FeatureFlag {
|
||||
id: string
|
||||
key: string
|
||||
enabled: boolean
|
||||
description?: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UpdateFeatureFlagDto {
|
||||
enabled: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface FeatureFlagsApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
originalError?: unknown
|
||||
name: 'FeatureFlagsApiError'
|
||||
}
|
||||
|
||||
export function createFeatureFlagsApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
originalError?: unknown
|
||||
): FeatureFlagsApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
originalError,
|
||||
name: 'FeatureFlagsApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isFeatureFlagsApiError(error: unknown): error is FeatureFlagsApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'FeatureFlagsApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const featureFlagsApi = {
|
||||
getFeatureFlags: async (): Promise<FeatureFlag[]> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/admin/feature-flags')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
throw createFeatureFlagsApiError(
|
||||
axiosError.response?.data?.message || 'Failed to load feature flags',
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getFeatureFlag: async (key: string): Promise<FeatureFlag> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/admin/feature-flags/${key}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createFeatureFlagsApiError(
|
||||
`Feature flag "${key}" not found`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createFeatureFlagsApiError(
|
||||
axiosError.response?.data?.message || `Failed to load feature flag ${key}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
updateFeatureFlag: async (
|
||||
key: string,
|
||||
data: UpdateFeatureFlagDto
|
||||
): Promise<FeatureFlag> => {
|
||||
try {
|
||||
const response = await adminApiClient.put(`/api/admin/feature-flags/${key}`, data)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createFeatureFlagsApiError(
|
||||
`Feature flag "${key}" not found`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createFeatureFlagsApiError(
|
||||
axiosError.response?.data?.message || `Failed to update feature flag ${key}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +34,17 @@ export interface ThemeSettings {
|
|||
particleDurationMin?: number
|
||||
particleDurationMax?: number
|
||||
particleInitialDelayMax?: number
|
||||
// Finish Screen Settings
|
||||
finishScreenTitle?: string
|
||||
finishScreenSubtitle?: string
|
||||
finishScreenBgColor?: string
|
||||
finishScreenCardBg?: string
|
||||
finishScreenTop3Enabled?: boolean
|
||||
finishScreenFirstPlaceColor?: string
|
||||
finishScreenSecondPlaceColor?: string
|
||||
finishScreenThirdPlaceColor?: string
|
||||
finishScreenAnimationEnabled?: boolean
|
||||
finishScreenAnimationDelay?: number
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
|
|
@ -42,6 +53,8 @@ export interface Theme {
|
|||
icon?: string | null
|
||||
description?: string | null
|
||||
isPublic: boolean
|
||||
isDefault?: boolean
|
||||
displayOrder?: number
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
createdAt: string
|
||||
|
|
@ -59,6 +72,8 @@ export interface ThemePreview {
|
|||
icon?: string | null
|
||||
description?: string | null
|
||||
isPublic: boolean
|
||||
isDefault?: boolean
|
||||
displayOrder?: number
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
createdAt: string
|
||||
|
|
@ -73,6 +88,8 @@ export interface CreateThemeDto {
|
|||
icon?: string
|
||||
description?: string
|
||||
isPublic?: boolean
|
||||
isDefault?: boolean
|
||||
displayOrder?: number
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
}
|
||||
|
|
@ -82,6 +99,8 @@ export interface UpdateThemeDto {
|
|||
icon?: string
|
||||
description?: string
|
||||
isPublic?: boolean
|
||||
isDefault?: boolean
|
||||
displayOrder?: number
|
||||
colors?: ThemeColors
|
||||
settings?: ThemeSettings
|
||||
}
|
||||
|
|
@ -226,6 +245,43 @@ export const themesApi = {
|
|||
)
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultTheme: async (themeId: string): Promise<Theme> => {
|
||||
try {
|
||||
const response = await adminApiClient.patch(`/api/admin/themes/${themeId}/set-default`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createThemesApiError(
|
||||
`Theme not found: The theme with ID "${themeId}" does not exist.`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createThemesApiError(
|
||||
axiosError.response?.data?.message || `Failed to set default theme ${themeId}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
reorderThemes: async (themeIds: string[]): Promise<Theme[]> => {
|
||||
try {
|
||||
const response = await adminApiClient.patch('/api/admin/themes/reorder', {
|
||||
themeIds,
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
throw createThemesApiError(
|
||||
axiosError.response?.data?.message || 'Failed to reorder themes',
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_COLORS: ThemeColors = {
|
||||
|
|
@ -261,4 +317,14 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
|
|||
particleDurationMin: 7,
|
||||
particleDurationMax: 10,
|
||||
particleInitialDelayMax: 10,
|
||||
finishScreenTitle: 'Игра завершена!',
|
||||
finishScreenSubtitle: '',
|
||||
finishScreenBgColor: 'rgba(0, 0, 0, 0.5)',
|
||||
finishScreenCardBg: 'rgba(255, 255, 255, 0.15)',
|
||||
finishScreenTop3Enabled: true,
|
||||
finishScreenFirstPlaceColor: '#ffd700',
|
||||
finishScreenSecondPlaceColor: '#c0c0c0',
|
||||
finishScreenThirdPlaceColor: '#cd7f32',
|
||||
finishScreenAnimationEnabled: true,
|
||||
finishScreenAnimationDelay: 100,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { roomsApi, type CreateAdminRoomDto } from '@/api/rooms'
|
|||
import { usersApi } from '@/api/users'
|
||||
import { themesApi } from '@/api/themes'
|
||||
import { packsApi } from '@/api/packs'
|
||||
import { useFeatureFlags } from '@/hooks/useFeatureFlags'
|
||||
import type { AxiosError } from 'axios'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -38,6 +39,8 @@ export function CreateAdminRoomDialog({
|
|||
onClose,
|
||||
onSuccess,
|
||||
}: CreateAdminRoomDialogProps) {
|
||||
const { isThemesEnabled } = useFeatureFlags()
|
||||
|
||||
const [formData, setFormData] = useState<CreateAdminRoomDto>({
|
||||
hostId: '',
|
||||
hostName: 'Ведущий',
|
||||
|
|
@ -69,6 +72,7 @@ export function CreateAdminRoomDialog({
|
|||
const { data: themesData } = useQuery({
|
||||
queryKey: ['themes'],
|
||||
queryFn: () => themesApi.getThemes({ page: 1, limit: 100 }),
|
||||
enabled: isThemesEnabled(),
|
||||
})
|
||||
|
||||
const { data: packsData } = useQuery({
|
||||
|
|
@ -250,6 +254,7 @@ export function CreateAdminRoomDialog({
|
|||
</Card>
|
||||
|
||||
{/* Theme Selection */}
|
||||
{isThemesEnabled() && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="themeId">Theme</Label>
|
||||
<Select
|
||||
|
|
@ -271,6 +276,7 @@ export function CreateAdminRoomDialog({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Pack */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -304,6 +310,7 @@ export function CreateAdminRoomDialog({
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isThemesEnabled() && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowThemeChange"
|
||||
|
|
@ -322,6 +329,7 @@ export function CreateAdminRoomDialog({
|
|||
Allow theme change
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowPackChange"
|
||||
|
|
|
|||
|
|
@ -709,6 +709,107 @@ export function ThemeEditorDialog({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finish Screen Section */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-lg font-semibold">Finish Screen (Экран завершения)</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Finish Screen Title</Label>
|
||||
<Input
|
||||
value={settings.finishScreenTitle || ''}
|
||||
onChange={(e) => updateSetting('finishScreenTitle', e.target.value)}
|
||||
placeholder="Игра завершена!"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Заголовок экрана завершения игры
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Finish Screen Subtitle</Label>
|
||||
<Input
|
||||
value={settings.finishScreenSubtitle || ''}
|
||||
onChange={(e) => updateSetting('finishScreenSubtitle', e.target.value)}
|
||||
placeholder="Победитель игры"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Подзаголовок экрана завершения (опционально)
|
||||
</p>
|
||||
</div>
|
||||
<ColorField
|
||||
label="Finish Screen Background"
|
||||
value={settings.finishScreenBgColor || 'rgba(0, 0, 0, 0.5)'}
|
||||
onChange={(v) => updateSetting('finishScreenBgColor', v)}
|
||||
description="Фон экрана завершения. Может отличаться от основного фона темы"
|
||||
/>
|
||||
<ColorField
|
||||
label="Finish Screen Card Background"
|
||||
value={settings.finishScreenCardBg || 'rgba(255, 255, 255, 0.15)'}
|
||||
onChange={(v) => updateSetting('finishScreenCardBg', v)}
|
||||
description="Фон карточек игроков на экране завершения"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="finishScreenTop3Enabled"
|
||||
checked={settings.finishScreenTop3Enabled ?? true}
|
||||
onCheckedChange={(checked) => updateSetting('finishScreenTop3Enabled', checked)}
|
||||
/>
|
||||
<Label htmlFor="finishScreenTop3Enabled" className="cursor-pointer">
|
||||
Highlight Top 3 (Выделять топ-3 игроков)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="finishScreenAnimationEnabled"
|
||||
checked={settings.finishScreenAnimationEnabled ?? true}
|
||||
onCheckedChange={(checked) => updateSetting('finishScreenAnimationEnabled', checked)}
|
||||
/>
|
||||
<Label htmlFor="finishScreenAnimationEnabled" className="cursor-pointer">
|
||||
Enable Animation (Включить анимацию появления карточек)
|
||||
</Label>
|
||||
</div>
|
||||
<ColorField
|
||||
label="First Place Color"
|
||||
value={settings.finishScreenFirstPlaceColor || '#ffd700'}
|
||||
onChange={(v) => updateSetting('finishScreenFirstPlaceColor', v)}
|
||||
description="Цвет для 1-го места (золотой)"
|
||||
/>
|
||||
<ColorField
|
||||
label="Second Place Color"
|
||||
value={settings.finishScreenSecondPlaceColor || '#c0c0c0'}
|
||||
onChange={(v) => updateSetting('finishScreenSecondPlaceColor', v)}
|
||||
description="Цвет для 2-го места (серебряный)"
|
||||
/>
|
||||
<ColorField
|
||||
label="Third Place Color"
|
||||
value={settings.finishScreenThirdPlaceColor || '#cd7f32'}
|
||||
onChange={(v) => updateSetting('finishScreenThirdPlaceColor', v)}
|
||||
description="Цвет для 3-го места (бронзовый)"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="finishScreenAnimationDelay">
|
||||
Animation Delay (мс)
|
||||
</Label>
|
||||
<Input
|
||||
id="finishScreenAnimationDelay"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000"
|
||||
step="50"
|
||||
value={settings.finishScreenAnimationDelay ?? 100}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10)
|
||||
if (!isNaN(value) && value >= 0) {
|
||||
updateSetting('finishScreenAnimationDelay', value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Задержка между появлением карточек игроков в миллисекундах
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFeatureFlags } from '@/hooks/useFeatureFlags'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
|
@ -10,28 +11,43 @@ import {
|
|||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
DoorOpen
|
||||
DoorOpen,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Packs', href: '/packs', icon: Package },
|
||||
{ name: 'Users', href: '/users', icon: Users },
|
||||
{ name: 'Themes', href: '/themes', icon: Palette },
|
||||
{ name: 'Rooms', href: '/rooms', icon: DoorOpen },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { isThemesEnabled, flags } = useFeatureFlags()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const navigation = useMemo(() => {
|
||||
const baseNavigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Packs', href: '/packs', icon: Package },
|
||||
{ name: 'Users', href: '/users', icon: Users },
|
||||
{ name: 'Rooms', href: '/rooms', icon: DoorOpen },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
if (isThemesEnabled()) {
|
||||
const themesIndex = baseNavigation.findIndex((item) => item.name === 'Users')
|
||||
baseNavigation.splice(themesIndex + 1, 0, {
|
||||
name: 'Themes',
|
||||
href: '/themes',
|
||||
icon: Palette,
|
||||
})
|
||||
}
|
||||
|
||||
return baseNavigation
|
||||
}, [isThemesEnabled, flags])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
|
|
|
|||
59
admin/src/hooks/useFeatureFlags.ts
Normal file
59
admin/src/hooks/useFeatureFlags.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { featureFlagsApi, type FeatureFlag, type UpdateFeatureFlagDto } from '@/api/featureFlags'
|
||||
import { toast } from 'sonner'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export function useFeatureFlags() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
data: flags,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<FeatureFlag[]>({
|
||||
queryKey: ['feature-flags'],
|
||||
queryFn: () => featureFlagsApi.getFeatureFlags(),
|
||||
staleTime: 30000, // 30 seconds
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ key, data }: { key: string; data: UpdateFeatureFlagDto }) =>
|
||||
featureFlagsApi.updateFeatureFlag(key, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['feature-flags'] })
|
||||
toast.success('Feature flag updated successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to update feature flag')
|
||||
},
|
||||
})
|
||||
|
||||
const isThemesEnabled = (): boolean => {
|
||||
if (!flags) return true // Fallback to enabled if flags not loaded
|
||||
const flag = flags.find((f) => f.key === 'themesEnabled')
|
||||
return flag?.enabled ?? true
|
||||
}
|
||||
|
||||
const isVoiceModeEnabled = (): boolean => {
|
||||
if (!flags) return true // Fallback to enabled if flags not loaded
|
||||
const flag = flags.find((f) => f.key === 'voiceModeEnabled')
|
||||
return flag?.enabled ?? true
|
||||
}
|
||||
|
||||
const updateFeatureFlag = (key: string, data: UpdateFeatureFlagDto) => {
|
||||
updateMutation.mutate({ key, data })
|
||||
}
|
||||
|
||||
return {
|
||||
flags,
|
||||
isLoading,
|
||||
error,
|
||||
isThemesEnabled,
|
||||
isVoiceModeEnabled,
|
||||
updateFeatureFlag,
|
||||
isUpdating: updateMutation.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
115
admin/src/pages/FeatureFlagsPage.tsx
Normal file
115
admin/src/pages/FeatureFlagsPage.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useFeatureFlags } from '@/hooks/useFeatureFlags'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Settings } from 'lucide-react'
|
||||
|
||||
export default function FeatureFlagsPage() {
|
||||
const {
|
||||
flags,
|
||||
isLoading,
|
||||
error,
|
||||
isThemesEnabled,
|
||||
isVoiceModeEnabled,
|
||||
updateFeatureFlag,
|
||||
isUpdating,
|
||||
} = useFeatureFlags()
|
||||
|
||||
const handleToggle = (key: string, enabled: boolean) => {
|
||||
updateFeatureFlag(key, { enabled })
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Feature Flags</h1>
|
||||
<p className="text-muted-foreground">Error loading feature flags</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-500">Failed to load feature flags. Please try again later.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Feature Flags</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage global feature flags to enable or disable functionality across the admin panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">Loading feature flags...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available Features</CardTitle>
|
||||
<CardDescription>
|
||||
Toggle features on or off to control their visibility and availability
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Themes Feature Flag */}
|
||||
<div className="flex items-start justify-between border-b pb-4 pt-2">
|
||||
<div className="space-y-1 flex-1 mr-4">
|
||||
<Label htmlFor="themes-enabled" className="text-base font-semibold cursor-pointer">
|
||||
Themes
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{flags?.find((f) => f.key === 'themesEnabled')?.description ||
|
||||
'Enable or disable themes functionality globally. When disabled, theme selection will be hidden in navigation and room creation.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<Checkbox
|
||||
id="themes-enabled"
|
||||
checked={isThemesEnabled()}
|
||||
onCheckedChange={(checked) => handleToggle('themesEnabled', checked as boolean)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Mode Feature Flag */}
|
||||
<div className="flex items-start justify-between border-b pb-4 pt-2">
|
||||
<div className="space-y-1 flex-1 mr-4">
|
||||
<Label htmlFor="voice-mode-enabled" className="text-base font-semibold cursor-pointer">
|
||||
Voice Mode
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{flags?.find((f) => f.key === 'voiceModeEnabled')?.description ||
|
||||
'Enable or disable voice mode functionality globally. When disabled, voice mode controls will be hidden in room creation and settings.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<Checkbox
|
||||
id="voice-mode-enabled"
|
||||
checked={isVoiceModeEnabled()}
|
||||
onCheckedChange={(checked) => handleToggle('voiceModeEnabled', checked as boolean)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flags && flags.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No feature flags available
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,23 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
themesApi,
|
||||
isThemesApiError,
|
||||
|
|
@ -33,10 +50,127 @@ import {
|
|||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload } from 'lucide-react'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, Star, GripVertical } from 'lucide-react'
|
||||
import { ThemeEditorDialog } from '@/components/ThemeEditorDialog'
|
||||
import { ThemeImportDialog } from '@/components/ThemeImportDialog'
|
||||
|
||||
interface SortableRowProps {
|
||||
theme: ThemePreview
|
||||
onEdit: (theme: ThemePreview) => void
|
||||
onDelete: (theme: ThemePreview) => void
|
||||
onSetDefault: (themeId: string) => void
|
||||
}
|
||||
|
||||
function SortableRow({ theme, onEdit, onDelete, onSetDefault }: SortableRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: theme.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isDragging ? 'bg-muted' : ''}>
|
||||
<TableCell>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<div
|
||||
className="w-16 h-10 rounded-md border"
|
||||
style={{
|
||||
background: theme.colors.bgPrimary,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
color: theme.colors.accentPrimary,
|
||||
textShadow: `0 0 4px ${theme.colors.textGlow}`,
|
||||
}}
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{theme.name}
|
||||
{theme.isDefault && (
|
||||
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
theme.isPublic
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{theme.isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{theme.isDefault ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Default
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{theme.displayOrder ?? 0}</TableCell>
|
||||
<TableCell>{theme.creator?.name || 'Unknown'}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(theme.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{!theme.isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSetDefault(theme.id)}
|
||||
title="Set as default"
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(theme)}
|
||||
title="Edit theme"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(theme)}
|
||||
title="Delete theme"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ThemesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
|
|
@ -110,6 +244,55 @@ export default function ThemesPage() {
|
|||
},
|
||||
})
|
||||
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: (themeId: string) => themesApi.setDefaultTheme(themeId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['themes'] })
|
||||
toast.success('Default theme updated successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isThemesApiError(error) ? error.message : 'Failed to set default theme'
|
||||
toast.error(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (themeIds: string[]) => themesApi.reorderThemes(themeIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['themes'] })
|
||||
toast.success('Themes reordered successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isThemesApiError(error) ? error.message : 'Failed to reorder themes'
|
||||
toast.error(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id && data?.themes) {
|
||||
const oldIndex = data.themes.findIndex((theme) => theme.id === active.id)
|
||||
const newIndex = data.themes.findIndex((theme) => theme.id === over.id)
|
||||
|
||||
const newThemes = arrayMove(data.themes, oldIndex, newIndex)
|
||||
const themeIds = newThemes.map((theme) => theme.id)
|
||||
|
||||
reorderMutation.mutate(themeIds)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDefault = (themeId: string) => {
|
||||
setDefaultMutation.mutate(themeId)
|
||||
}
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditingTheme(null)
|
||||
setImportedData(null)
|
||||
|
|
@ -253,87 +436,56 @@ export default function ThemesPage() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Themes ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>Manage theme colors and settings</CardDescription>
|
||||
<CardDescription>
|
||||
Manage theme colors and settings. Drag rows to reorder themes on this page. Set default theme with star icon.
|
||||
{data && data.total > limit && (
|
||||
<span className="block mt-1 text-amber-600 dark:text-amber-400">
|
||||
Note: Reordering works per page. For full control, increase items per page or use search to filter.
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading themes...</div>
|
||||
) : (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Preview</TableHead>
|
||||
<TableHead className="w-[100px]">Preview</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Public</TableHead>
|
||||
<TableHead>Default</TableHead>
|
||||
<TableHead className="w-[80px]">Order</TableHead>
|
||||
<TableHead>Creator</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead className="w-[150px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={data?.themes.map((t) => t.id) || []}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(data?.themes || []).map((theme) => (
|
||||
<TableRow key={theme.id}>
|
||||
<TableCell>
|
||||
<div
|
||||
className="w-16 h-10 rounded-md border"
|
||||
style={{
|
||||
background: theme.colors.bgPrimary,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
color: theme.colors.accentPrimary,
|
||||
textShadow: `0 0 4px ${theme.colors.textGlow}`,
|
||||
}}
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{theme.name}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
theme.isPublic
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{theme.isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{theme.creator?.name || 'Unknown'}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(theme.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditEditor(theme)}
|
||||
title="Edit theme"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(theme)}
|
||||
title="Delete theme"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<SortableRow
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
onEdit={openEditEditor}
|
||||
onDelete={handleDelete}
|
||||
onSetDefault={handleSetDefault}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
|
|
|
|||
|
|
@ -192,6 +192,8 @@ model Theme {
|
|||
icon String?
|
||||
description String?
|
||||
isPublic Boolean @default(false)
|
||||
isDefault Boolean @default(false)
|
||||
displayOrder Int @default(0)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
@ -201,4 +203,17 @@ model Theme {
|
|||
|
||||
creator User @relation(fields: [createdBy], references: [id])
|
||||
rooms Room[]
|
||||
|
||||
@@index([isDefault])
|
||||
@@index([displayOrder])
|
||||
}
|
||||
|
||||
model FeatureFlag {
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
enabled Boolean @default(true)
|
||||
description String?
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@index([key])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -439,6 +439,36 @@ async function main() {
|
|||
console.log(`Theme "${theme.name}" created/updated`);
|
||||
}
|
||||
|
||||
// Create default feature flags
|
||||
const defaultFeatureFlags = [
|
||||
{
|
||||
key: 'themesEnabled',
|
||||
enabled: true,
|
||||
description: 'Enable/disable themes functionality globally',
|
||||
},
|
||||
{
|
||||
key: 'voiceModeEnabled',
|
||||
enabled: true,
|
||||
description: 'Enable/disable voice mode functionality globally',
|
||||
},
|
||||
];
|
||||
|
||||
for (const flag of defaultFeatureFlags) {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { key: flag.key },
|
||||
update: {
|
||||
enabled: flag.enabled,
|
||||
description: flag.description,
|
||||
},
|
||||
create: {
|
||||
key: flag.key,
|
||||
enabled: flag.enabled,
|
||||
description: flag.description,
|
||||
},
|
||||
});
|
||||
console.log(`Feature flag "${flag.key}" created/updated`);
|
||||
}
|
||||
|
||||
console.log('Seed completed successfully!');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ import { AdminGameHistoryService } from './game-history/admin-game-history.servi
|
|||
import { AdminThemesController } from './themes/admin-themes.controller';
|
||||
import { AdminThemesService } from './themes/admin-themes.service';
|
||||
|
||||
// Feature Flags
|
||||
import { AdminFeatureFlagsController } from './feature-flags/admin-feature-flags.controller';
|
||||
import { AdminFeatureFlagsService } from './feature-flags/admin-feature-flags.service';
|
||||
|
||||
// Guards
|
||||
import { AdminAuthGuard } from './guards/admin-auth.guard';
|
||||
import { AdminGuard } from './guards/admin.guard';
|
||||
|
|
@ -61,6 +65,7 @@ import { AdminGuard } from './guards/admin.guard';
|
|||
AdminAnalyticsController,
|
||||
AdminGameHistoryController,
|
||||
AdminThemesController,
|
||||
AdminFeatureFlagsController,
|
||||
],
|
||||
providers: [
|
||||
AdminAuthService,
|
||||
|
|
@ -70,6 +75,7 @@ import { AdminGuard } from './guards/admin.guard';
|
|||
AdminAnalyticsService,
|
||||
AdminGameHistoryService,
|
||||
AdminThemesService,
|
||||
AdminFeatureFlagsService,
|
||||
AdminAuthGuard,
|
||||
AdminGuard,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AdminFeatureFlagsService } from './admin-feature-flags.service';
|
||||
import { UpdateFeatureFlagDto } from './dto/update-feature-flag.dto';
|
||||
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
||||
import { AdminGuard } from '../guards/admin.guard';
|
||||
|
||||
@Controller('api/admin/feature-flags')
|
||||
@UseGuards(AdminAuthGuard, AdminGuard)
|
||||
export class AdminFeatureFlagsController {
|
||||
constructor(private readonly adminFeatureFlagsService: AdminFeatureFlagsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.adminFeatureFlagsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':key')
|
||||
findOne(@Param('key') key: string) {
|
||||
return this.adminFeatureFlagsService.findOne(key);
|
||||
}
|
||||
|
||||
@Put(':key')
|
||||
update(@Param('key') key: string, @Body() updateDto: UpdateFeatureFlagDto) {
|
||||
return this.adminFeatureFlagsService.update(key, updateDto);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { UpdateFeatureFlagDto } from './dto/update-feature-flag.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminFeatureFlagsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
const flags = await this.prisma.featureFlag.findMany({
|
||||
orderBy: { key: 'asc' },
|
||||
});
|
||||
return flags;
|
||||
}
|
||||
|
||||
async findOne(key: string) {
|
||||
const flag = await this.prisma.featureFlag.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
if (!flag) {
|
||||
throw new NotFoundException(`Feature flag with key "${key}" not found`);
|
||||
}
|
||||
|
||||
return flag;
|
||||
}
|
||||
|
||||
async update(key: string, dto: UpdateFeatureFlagDto) {
|
||||
try {
|
||||
const flag = await this.prisma.featureFlag.update({
|
||||
where: { key },
|
||||
data: {
|
||||
enabled: dto.enabled,
|
||||
description: dto.description,
|
||||
},
|
||||
});
|
||||
|
||||
return flag;
|
||||
} catch (error) {
|
||||
if (error.code === 'P2025') {
|
||||
throw new NotFoundException(`Feature flag with key "${key}" not found`);
|
||||
}
|
||||
throw new BadRequestException(`Failed to update feature flag: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async upsert(key: string, dto: UpdateFeatureFlagDto & { description?: string }) {
|
||||
const flag = await this.prisma.featureFlag.upsert({
|
||||
where: { key },
|
||||
update: {
|
||||
enabled: dto.enabled,
|
||||
description: dto.description,
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
enabled: dto.enabled,
|
||||
description: dto.description || null,
|
||||
},
|
||||
});
|
||||
|
||||
return flag;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateFeatureFlagDto {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ import { AdminThemesService } from './admin-themes.service';
|
|||
import { ThemeFiltersDto } from './dto/theme-filters.dto';
|
||||
import { CreateThemeDto } from './dto/create-theme.dto';
|
||||
import { UpdateThemeDto } from './dto/update-theme.dto';
|
||||
import { ReorderThemesDto } from './dto/reorder-themes.dto';
|
||||
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
||||
import { AdminGuard } from '../guards/admin.guard';
|
||||
|
||||
|
|
@ -46,4 +47,14 @@ export class AdminThemesController {
|
|||
remove(@Param('id') id: string) {
|
||||
return this.adminThemesService.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/set-default')
|
||||
setDefault(@Param('id') id: string) {
|
||||
return this.adminThemesService.setDefaultTheme(id);
|
||||
}
|
||||
|
||||
@Patch('reorder')
|
||||
reorder(@Body() reorderDto: ReorderThemesDto) {
|
||||
return this.adminThemesService.reorderThemes(reorderDto.themeIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export class AdminThemesService {
|
|||
name: true,
|
||||
description: true,
|
||||
isPublic: true,
|
||||
isDefault: true,
|
||||
displayOrder: true,
|
||||
colors: true,
|
||||
settings: true,
|
||||
createdAt: true,
|
||||
|
|
@ -46,7 +48,10 @@ export class AdminThemesService {
|
|||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: [
|
||||
{ displayOrder: 'asc' },
|
||||
{ name: 'asc' },
|
||||
],
|
||||
}),
|
||||
this.prisma.theme.count({ where }),
|
||||
]);
|
||||
|
|
@ -82,12 +87,32 @@ export class AdminThemesService {
|
|||
}
|
||||
|
||||
async create(createThemeDto: CreateThemeDto, createdBy: string) {
|
||||
// Если устанавливается тема по умолчанию, снимаем флаг с остальных
|
||||
if (createThemeDto.isDefault === true) {
|
||||
await this.prisma.theme.updateMany({
|
||||
where: { isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Определяем displayOrder: если не указан, ставим максимальный + 1
|
||||
let displayOrder = createThemeDto.displayOrder;
|
||||
if (displayOrder === undefined) {
|
||||
const maxOrderTheme = await this.prisma.theme.findFirst({
|
||||
orderBy: { displayOrder: 'desc' },
|
||||
select: { displayOrder: true },
|
||||
});
|
||||
displayOrder = (maxOrderTheme?.displayOrder ?? -1) + 1;
|
||||
}
|
||||
|
||||
return this.prisma.theme.create({
|
||||
data: {
|
||||
name: createThemeDto.name,
|
||||
icon: createThemeDto.icon,
|
||||
description: createThemeDto.description,
|
||||
isPublic: createThemeDto.isPublic ?? true,
|
||||
isDefault: createThemeDto.isDefault ?? false,
|
||||
displayOrder,
|
||||
createdBy,
|
||||
colors: JSON.parse(JSON.stringify(createThemeDto.colors)) as Prisma.InputJsonValue,
|
||||
settings: JSON.parse(JSON.stringify(createThemeDto.settings)) as Prisma.InputJsonValue,
|
||||
|
|
@ -113,6 +138,14 @@ export class AdminThemesService {
|
|||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
// Если устанавливается тема по умолчанию, снимаем флаг с остальных
|
||||
if (updateThemeDto.isDefault === true) {
|
||||
await this.prisma.theme.updateMany({
|
||||
where: { isDefault: true, id: { not: id } },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
const updateData: Prisma.ThemeUpdateInput = {
|
||||
...(updateThemeDto.name !== undefined && { name: updateThemeDto.name }),
|
||||
...(updateThemeDto.icon !== undefined && { icon: updateThemeDto.icon }),
|
||||
|
|
@ -120,6 +153,12 @@ export class AdminThemesService {
|
|||
...(updateThemeDto.isPublic !== undefined && {
|
||||
isPublic: updateThemeDto.isPublic,
|
||||
}),
|
||||
...(updateThemeDto.isDefault !== undefined && {
|
||||
isDefault: updateThemeDto.isDefault,
|
||||
}),
|
||||
...(updateThemeDto.displayOrder !== undefined && {
|
||||
displayOrder: updateThemeDto.displayOrder,
|
||||
}),
|
||||
...(updateThemeDto.colors && {
|
||||
colors: JSON.parse(
|
||||
JSON.stringify(updateThemeDto.colors),
|
||||
|
|
@ -173,8 +212,81 @@ export class AdminThemesService {
|
|||
description: true,
|
||||
colors: true,
|
||||
settings: true,
|
||||
isDefault: true,
|
||||
displayOrder: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
orderBy: [
|
||||
{ displayOrder: 'asc' },
|
||||
{ name: 'asc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async setDefaultTheme(id: string) {
|
||||
const theme = await this.prisma.theme.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!theme) {
|
||||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
// Снимаем флаг isDefault со всех тем
|
||||
await this.prisma.theme.updateMany({
|
||||
where: { isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
|
||||
// Устанавливаем флаг для выбранной темы
|
||||
return this.prisma.theme.update({
|
||||
where: { id },
|
||||
data: { isDefault: true },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async reorderThemes(themeIds: string[]) {
|
||||
// Получаем текущие displayOrder для переупорядочиваемых тем
|
||||
const currentThemes = await this.prisma.theme.findMany({
|
||||
where: { id: { in: themeIds } },
|
||||
select: { id: true, displayOrder: true },
|
||||
});
|
||||
|
||||
// Находим минимальный displayOrder среди переупорядочиваемых тем
|
||||
// Это будет стартовая позиция для нового порядка
|
||||
const minOrder = currentThemes.length > 0
|
||||
? Math.min(...currentThemes.map((t) => t.displayOrder))
|
||||
: 0;
|
||||
|
||||
// Обновляем displayOrder последовательно, начиная с minOrder
|
||||
// Это сохранит относительный порядок относительно других тем
|
||||
const updates = themeIds.map((themeId, index) =>
|
||||
this.prisma.theme.update({
|
||||
where: { id: themeId },
|
||||
data: { displayOrder: minOrder + index },
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
// Возвращаем обновленные темы
|
||||
return this.prisma.theme.findMany({
|
||||
where: { id: { in: themeIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isDefault: true,
|
||||
displayOrder: true,
|
||||
},
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,48 @@ export class ThemeSettingsDto {
|
|||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
particleInitialDelayMax?: number;
|
||||
|
||||
// Finish Screen Settings
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
finishScreenTitle?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
finishScreenSubtitle?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
finishScreenBgColor?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
finishScreenCardBg?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
finishScreenTop3Enabled?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
finishScreenFirstPlaceColor?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
finishScreenSecondPlaceColor?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
finishScreenThirdPlaceColor?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
finishScreenAnimationEnabled?: boolean;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
finishScreenAnimationDelay?: number;
|
||||
}
|
||||
|
||||
export class CreateThemeDto {
|
||||
|
|
@ -129,6 +171,15 @@ export class CreateThemeDto {
|
|||
@IsOptional()
|
||||
isPublic?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
displayOrder?: number;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ThemeColorsDto)
|
||||
|
|
|
|||
9
backend/src/admin/themes/dto/reorder-themes.dto.ts
Normal file
9
backend/src/admin/themes/dto/reorder-themes.dto.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { IsArray, IsString, ArrayMinSize } from 'class-validator';
|
||||
|
||||
export class ReorderThemesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
themeIds: string[];
|
||||
}
|
||||
|
||||
|
|
@ -247,11 +247,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
|||
currentRevealed.push(payload.answerId);
|
||||
revealed[payload.questionId] = currentRevealed;
|
||||
|
||||
// Начисляем очки
|
||||
// Начисляем очки текущему игроку (не тому, кто открыл ответ)
|
||||
if (room.currentPlayerId) {
|
||||
await this.prisma.participant.update({
|
||||
where: { id: payload.participantId },
|
||||
where: { id: room.currentPlayerId },
|
||||
data: { score: { increment: answer.points } }
|
||||
});
|
||||
}
|
||||
|
||||
// Сохраняем revealedAnswers
|
||||
await this.prisma.room.update({
|
||||
|
|
@ -259,9 +261,9 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
|||
data: { revealedAnswers: revealed as Prisma.InputJsonValue }
|
||||
});
|
||||
|
||||
// Определяем следующего игрока
|
||||
// Определяем следующего игрока на основе текущего игрока
|
||||
const participants = room.participants;
|
||||
const currentIdx = participants.findIndex((p) => p.id === payload.participantId);
|
||||
const currentIdx = participants.findIndex((p) => p.id === room.currentPlayerId);
|
||||
const nextIdx = (currentIdx + 1) % participants.length;
|
||||
const nextPlayerId = participants[nextIdx]?.id;
|
||||
|
||||
|
|
@ -333,7 +335,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
|||
}
|
||||
|
||||
// КЛЮЧЕВОЙ МЕТОД: Отправка полного состояния
|
||||
private async broadcastFullState(roomCode: string) {
|
||||
public async broadcastFullState(roomCode: string) {
|
||||
const room = (await this.prisma.room.findUnique({
|
||||
where: { code: roomCode },
|
||||
include: {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@ import { RoomPackModule } from '../room-pack/room-pack.module';
|
|||
@Module({
|
||||
imports: [forwardRef(() => RoomsModule), RoomPackModule],
|
||||
providers: [GameGateway, RoomEventsService],
|
||||
exports: [RoomEventsService],
|
||||
exports: [RoomEventsService, GameGateway],
|
||||
})
|
||||
export class GameModule {}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException,
|
|||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { RoomEventsService } from '../game/room-events.service';
|
||||
import { GameGateway } from '../game/game.gateway';
|
||||
import { RoomPackService } from '../room-pack/room-pack.service';
|
||||
|
||||
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
|
||||
|
|
@ -12,6 +13,8 @@ export class RoomsService {
|
|||
private prisma: PrismaService,
|
||||
@Inject(forwardRef(() => RoomEventsService))
|
||||
private roomEventsService: RoomEventsService,
|
||||
@Inject(forwardRef(() => GameGateway))
|
||||
private gameGateway: GameGateway,
|
||||
private roomPackService: RoomPackService,
|
||||
) {}
|
||||
|
||||
|
|
@ -139,12 +142,16 @@ export class RoomsService {
|
|||
include: { user: true },
|
||||
},
|
||||
questionPack: true,
|
||||
roomPack: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Отправляем событие roomUpdate всем клиентам в комнате
|
||||
if (updatedRoom) {
|
||||
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
||||
// Также отправляем gameStateUpdated через broadcastFullState
|
||||
await this.gameGateway.broadcastFullState(updatedRoom.code);
|
||||
}
|
||||
|
||||
return participant;
|
||||
|
|
@ -231,6 +238,8 @@ export class RoomsService {
|
|||
// Отправляем событие roomUpdate всем клиентам в комнате
|
||||
if (updatedRoom) {
|
||||
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
||||
// Также отправляем gameStateUpdated через broadcastFullState
|
||||
await this.gameGateway.broadcastFullState(updatedRoom.code);
|
||||
}
|
||||
|
||||
return participant;
|
||||
|
|
@ -286,11 +295,14 @@ export class RoomsService {
|
|||
},
|
||||
questionPack: true,
|
||||
roomPack: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Отправляем обновление через WebSocket
|
||||
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||
// Также отправляем gameStateUpdated через broadcastFullState
|
||||
await this.gameGateway.broadcastFullState(room.code);
|
||||
|
||||
return room;
|
||||
}
|
||||
|
|
@ -312,11 +324,15 @@ export class RoomsService {
|
|||
},
|
||||
questionPack: true,
|
||||
roomPack: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (room) {
|
||||
this.roomEventsService.emitRoomPackUpdated(room.code, room);
|
||||
// Также отправляем gameStateUpdated через broadcastFullState для синхронизации вопросов
|
||||
// (если метод вызывается напрямую через REST API, а не через WebSocket)
|
||||
await this.gameGateway.broadcastFullState(room.code);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
|
@ -354,10 +370,14 @@ export class RoomsService {
|
|||
include: { user: true },
|
||||
},
|
||||
questionPack: true,
|
||||
roomPack: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||
// Также отправляем gameStateUpdated через broadcastFullState
|
||||
await this.gameGateway.broadcastFullState(room.code);
|
||||
return room;
|
||||
}
|
||||
|
||||
|
|
@ -428,11 +448,16 @@ export class RoomsService {
|
|||
include: { user: true },
|
||||
},
|
||||
questionPack: true,
|
||||
roomPack: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (room) {
|
||||
this.roomEventsService.emitPlayerKicked(room.code, { participantId, room });
|
||||
// Также отправляем gameStateUpdated через broadcastFullState для синхронизации участников
|
||||
// (если метод вызывается напрямую через REST API, а не через WebSocket)
|
||||
await this.gameGateway.broadcastFullState(room.code);
|
||||
}
|
||||
|
||||
return room;
|
||||
|
|
@ -521,11 +546,15 @@ export class RoomsService {
|
|||
include: { user: true },
|
||||
},
|
||||
questionPack: true,
|
||||
roomPack: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (room) {
|
||||
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||
// Также отправляем gameStateUpdated через broadcastFullState
|
||||
await this.gameGateway.broadcastFullState(room.code);
|
||||
}
|
||||
|
||||
return room;
|
||||
|
|
|
|||
|
|
@ -22,8 +22,13 @@ export class ThemesController {
|
|||
description: true,
|
||||
colors: true,
|
||||
settings: true,
|
||||
isDefault: true,
|
||||
displayOrder: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
orderBy: [
|
||||
{ displayOrder: 'asc' },
|
||||
{ name: 'asc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: clamp(8px, 1.5vh, 20px) clamp(12px, 1.5vw, 20px);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -20,6 +19,11 @@
|
|||
scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Cursor pointer only for clickable button state */
|
||||
.answer-button:not(.answer-revealed) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Горизонтальный layout для узких кнопок */
|
||||
@media (max-width: 1000px) {
|
||||
.answer-button {
|
||||
|
|
@ -30,21 +34,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
.answer-button:hover:not(:disabled) {
|
||||
/* Hover and active effects only for clickable button state (hidden answer) */
|
||||
.answer-button:not(.answer-revealed):hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(255, 215, 0, 0.4);
|
||||
border-color: rgba(255, 215, 0, 0.6);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.answer-button:active:not(:disabled) {
|
||||
.answer-button:not(.answer-revealed):active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.answer-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.answer-hidden {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
|
@ -93,7 +94,8 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.answer-button:hover:not(:disabled) .answer-points-hidden {
|
||||
/* Hover effect for hidden answer points */
|
||||
.answer-button:not(.answer-revealed):hover .answer-points-hidden {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: scale(1.1);
|
||||
|
|
|
|||
|
|
@ -32,13 +32,7 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
|||
}
|
||||
}, [isRevealed, autoPlayAnswers, roomId, questionId, answer.id, speak])
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`answer-button ${getAnswerClass()}`}
|
||||
onClick={onClick}
|
||||
disabled={isRevealed}
|
||||
style={
|
||||
isRevealed
|
||||
const commonStyle = isRevealed
|
||||
? {
|
||||
borderColor: getPointsColor(answer.points),
|
||||
background: `linear-gradient(135deg, ${getPointsColor(
|
||||
|
|
@ -46,9 +40,32 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
|||
)}20, ${getPointsColor(answer.points)}40)`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
|
||||
const commonClassName = `answer-button ${getAnswerClass()}`
|
||||
|
||||
// Use button when hidden (clickable), div when revealed (not clickable, allows nested buttons)
|
||||
if (!isRevealed) {
|
||||
return (
|
||||
<button
|
||||
className={commonClassName}
|
||||
onClick={onClick}
|
||||
style={commonStyle}
|
||||
>
|
||||
<span
|
||||
className="answer-points-hidden"
|
||||
style={{ color: getPointsColor(answer.points) }}
|
||||
>
|
||||
{answer.points}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={commonClassName}
|
||||
style={commonStyle}
|
||||
>
|
||||
{isRevealed ? (
|
||||
<div className="answer-revealed-content">
|
||||
<span className="answer-text">{answer.text}</span>
|
||||
<div className="answer-revealed-footer">
|
||||
|
|
@ -69,15 +86,7 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="answer-points-hidden"
|
||||
style={{ color: getPointsColor(answer.points) }}
|
||||
>
|
||||
{answer.points}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
margin-top: clamp(5px, 1vh, 10px);
|
||||
margin-bottom: clamp(5px, 1vh, 15px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: clamp(5px, 1vh, 10px);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
208
src/components/GameFinishedScreen.css
Normal file
208
src/components/GameFinishedScreen.css
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
.game-finished-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-finished-content {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.game-finished-title {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: var(--text-primary, #ffffff);
|
||||
text-shadow: 0 0 20px var(--text-glow, rgba(255, 215, 0, 0.5));
|
||||
margin: 0;
|
||||
animation: slideDown 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-finished-subtitle {
|
||||
font-size: clamp(1.2rem, 3vw, 1.8rem);
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.8));
|
||||
margin: 0;
|
||||
animation: slideDown 0.7s ease-out;
|
||||
}
|
||||
|
||||
.game-finished-players {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-finished-player-card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 24px;
|
||||
border-radius: var(--border-radius-md, 12px);
|
||||
border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.game-finished-player-card.animate-in {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
animation: slideUpFadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slideUpFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-finished-player-card.top-3 {
|
||||
border-width: 3px;
|
||||
box-shadow: 0 0 30px rgba(255, 215, 0, 0.3);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.game-finished-player-card:hover {
|
||||
transform: translateY(-5px) scale(1.01);
|
||||
box-shadow: var(--shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.game-finished-player-place {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary, #ffffff);
|
||||
min-width: 120px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-finished-player-name {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary, #ffffff);
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.game-finished-player-score {
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent-primary, #ffd700);
|
||||
text-align: right;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Адаптивный дизайн */
|
||||
@media (max-width: 768px) {
|
||||
.game-finished-content {
|
||||
padding: 20px 15px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.game-finished-player-card {
|
||||
padding: 18px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-finished-player-place {
|
||||
text-align: center;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.game-finished-player-name {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.game-finished-player-score {
|
||||
text-align: center;
|
||||
min-width: auto;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.game-finished-player-card.top-3 {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.game-finished-content {
|
||||
padding: 15px 10px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.game-finished-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.game-finished-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.game-finished-player-card {
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-finished-player-place {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.game-finished-player-name {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.game-finished-player-score {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
122
src/components/GameFinishedScreen.jsx
Normal file
122
src/components/GameFinishedScreen.jsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
import './GameFinishedScreen.css'
|
||||
|
||||
const GameFinishedScreen = ({ participants = [], playerScores = {} }) => {
|
||||
const { currentThemeData } = useTheme()
|
||||
const themeSettings = currentThemeData?.settings || {}
|
||||
|
||||
// Сортируем участников по очкам (от большего к меньшему)
|
||||
const sortedParticipants = useMemo(() => {
|
||||
return [...participants].sort((a, b) => {
|
||||
const scoreA = playerScores[a.id] || 0
|
||||
const scoreB = playerScores[b.id] || 0
|
||||
return scoreB - scoreA
|
||||
})
|
||||
}, [participants, playerScores])
|
||||
|
||||
// Получаем настройки с дефолтными значениями
|
||||
const title = themeSettings.finishScreenTitle || 'Игра завершена!'
|
||||
const subtitle = themeSettings.finishScreenSubtitle || ''
|
||||
const bgColor = themeSettings.finishScreenBgColor || 'rgba(0, 0, 0, 0.5)'
|
||||
const cardBg = themeSettings.finishScreenCardBg || 'rgba(255, 255, 255, 0.15)'
|
||||
const top3Enabled = themeSettings.finishScreenTop3Enabled !== false
|
||||
const animationEnabled = themeSettings.finishScreenAnimationEnabled !== false
|
||||
const animationDelay = themeSettings.finishScreenAnimationDelay || 100
|
||||
const firstPlaceColor = themeSettings.finishScreenFirstPlaceColor || '#ffd700'
|
||||
const secondPlaceColor = themeSettings.finishScreenSecondPlaceColor || '#c0c0c0'
|
||||
const thirdPlaceColor = themeSettings.finishScreenThirdPlaceColor || '#cd7f32'
|
||||
|
||||
const getPlaceColor = (index) => {
|
||||
if (!top3Enabled) return null
|
||||
if (index === 0) return firstPlaceColor
|
||||
if (index === 1) return secondPlaceColor
|
||||
if (index === 2) return thirdPlaceColor
|
||||
return null
|
||||
}
|
||||
|
||||
const getPlaceEmoji = (index) => {
|
||||
if (!top3Enabled) return null
|
||||
if (index === 0) return '🥇'
|
||||
if (index === 1) return '🥈'
|
||||
if (index === 2) return '🥉'
|
||||
return null
|
||||
}
|
||||
|
||||
const getPlaceLabel = (index) => {
|
||||
const place = index + 1
|
||||
const emoji = getPlaceEmoji(index)
|
||||
return emoji ? `${emoji} ${place} место` : `${place} место`
|
||||
}
|
||||
|
||||
if (sortedParticipants.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="game-finished-screen"
|
||||
style={{
|
||||
background: bgColor,
|
||||
}}
|
||||
>
|
||||
<div className="game-finished-content">
|
||||
<h1 className="game-finished-title">{title}</h1>
|
||||
{subtitle && <h2 className="game-finished-subtitle">{subtitle}</h2>}
|
||||
<p style={{ color: 'var(--text-secondary, rgba(255, 255, 255, 0.8))' }}>
|
||||
Нет участников
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-finished-screen"
|
||||
style={{
|
||||
background: bgColor,
|
||||
}}
|
||||
>
|
||||
<div className="game-finished-content">
|
||||
<h1 className="game-finished-title">{title}</h1>
|
||||
{subtitle && <h2 className="game-finished-subtitle">{subtitle}</h2>}
|
||||
|
||||
<div className="game-finished-players">
|
||||
{sortedParticipants.map((participant, index) => {
|
||||
const score = playerScores[participant.id] || 0
|
||||
const placeColor = getPlaceColor(index)
|
||||
const isTop3 = top3Enabled && index < 3
|
||||
|
||||
const cardStyle = {
|
||||
background: cardBg,
|
||||
animationDelay: animationEnabled ? `${index * animationDelay}ms` : '0ms',
|
||||
...(placeColor && {
|
||||
borderColor: placeColor,
|
||||
boxShadow: `0 0 20px ${placeColor}40`,
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={`game-finished-player-card ${isTop3 ? 'top-3' : ''} ${animationEnabled ? 'animate-in' : ''}`}
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="game-finished-player-place">
|
||||
{getPlaceLabel(index)}
|
||||
</div>
|
||||
<div className="game-finished-player-name">{participant.name}</div>
|
||||
<div
|
||||
className="game-finished-player-score"
|
||||
style={placeColor ? { color: placeColor } : {}}
|
||||
>
|
||||
{score} очков
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GameFinishedScreen
|
||||
|
|
@ -18,6 +18,7 @@ const GameManagementModal = ({
|
|||
availablePacks = [],
|
||||
onStartGame,
|
||||
onEndGame,
|
||||
onRestartGame,
|
||||
onNextQuestion,
|
||||
onPreviousQuestion,
|
||||
onRevealAnswer,
|
||||
|
|
@ -904,6 +905,15 @@ const GameManagementModal = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{gameStatus === 'FINISHED' && onRestartGame && (
|
||||
<button
|
||||
className="mgmt-button start-button"
|
||||
onClick={onRestartGame}
|
||||
>
|
||||
🔄 Перезапустить игру
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Управление ответами - показывается только во время активной игры */}
|
||||
{gameStatus === 'PLAYING' && currentQuestion && (
|
||||
<div className="answers-control-section">
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ export const ThemeProvider = ({ children }) => {
|
|||
// Set default theme if no theme is selected or current theme is not found
|
||||
setCurrentTheme((prevTheme) => {
|
||||
if (!prevTheme || !data.find((t) => t.id === prevTheme)) {
|
||||
const defaultTheme = data.find((t) => t.id === 'new-year') || data[0];
|
||||
// Find theme marked as default, or use first theme as fallback
|
||||
const defaultTheme = data.find((t) => t.isDefault === true) || data[0];
|
||||
if (defaultTheme) {
|
||||
localStorage.setItem('app-theme', defaultTheme.id);
|
||||
return defaultTheme.id;
|
||||
|
|
|
|||
|
|
@ -25,11 +25,16 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
|||
setParticipants(response.data.participants || []);
|
||||
setError(null);
|
||||
setRequiresPassword(false);
|
||||
|
||||
// ✅ Подключаться к WebSocket только после успешной загрузки комнаты
|
||||
socketService.connect();
|
||||
socketService.joinRoom(roomCode, user?.id);
|
||||
} catch (err) {
|
||||
// Проверяем, требуется ли пароль (401 Unauthorized)
|
||||
if (err.response?.status === 401) {
|
||||
setRequiresPassword(true);
|
||||
setError('Room password required');
|
||||
// ❌ НЕ подключаться к WebSocket, если требуется пароль
|
||||
} else {
|
||||
setError(err.response?.data?.message || err.message);
|
||||
setRequiresPassword(false);
|
||||
|
|
@ -42,13 +47,7 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
|||
|
||||
fetchRoom();
|
||||
|
||||
// Connect to WebSocket
|
||||
socketService.connect();
|
||||
|
||||
// Join the room via WebSocket
|
||||
socketService.joinRoom(roomCode, user?.id);
|
||||
|
||||
// Listen for room updates
|
||||
// Listen for room updates (регистрируются всегда, но не подключаются если requiresPassword)
|
||||
const handleRoomUpdate = (updatedRoom) => {
|
||||
setRoom(updatedRoom);
|
||||
setParticipants(updatedRoom.participants || []);
|
||||
|
|
@ -159,6 +158,11 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
|||
setParticipants(response.data.participants || []);
|
||||
setError(null);
|
||||
setRequiresPassword(false);
|
||||
|
||||
// ✅ После успешной загрузки комнаты с паролем подключаемся к WebSocket
|
||||
socketService.connect();
|
||||
socketService.joinRoom(roomCode, user?.id);
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { questionsApi, roomsApi } from '../services/api';
|
|||
import QRCode from 'qrcode';
|
||||
import socketService from '../services/socket';
|
||||
import Game from '../components/Game';
|
||||
import GameFinishedScreen from '../components/GameFinishedScreen';
|
||||
import QRModal from '../components/QRModal';
|
||||
import GameManagementModal from '../components/GameManagementModal';
|
||||
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||
|
|
@ -501,6 +502,13 @@ const GamePage = () => {
|
|||
)}
|
||||
|
||||
<div className="game-container">
|
||||
{gameState.status === 'FINISHED' ? (
|
||||
<GameFinishedScreen
|
||||
participants={gameState.participants}
|
||||
playerScores={playerScores}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{gameState.questions.length === 0 && (
|
||||
<div className="no-questions-banner">
|
||||
<p>
|
||||
|
|
@ -540,6 +548,8 @@ const GamePage = () => {
|
|||
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
|
||||
onSelectPlayer={isHost ? handleSelectPlayer : null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useRoom } from '../hooks/useRoom';
|
||||
import { questionsApi } from '../services/api';
|
||||
import QRCode from 'qrcode';
|
||||
|
|
@ -15,6 +16,10 @@ const RoomPage = () => {
|
|||
const { roomCode } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, loginAnonymous, loading: authLoading } = useAuth();
|
||||
const { changeTheme } = useTheme();
|
||||
|
||||
// Храним предыдущий themeId комнаты для отслеживания изменений
|
||||
const previousThemeIdRef = useRef(null);
|
||||
|
||||
// Callback для автоматической навигации при старте игры
|
||||
const handleGameStartedEvent = useCallback(() => {
|
||||
|
|
@ -69,20 +74,16 @@ const RoomPage = () => {
|
|||
}, [roomCode]);
|
||||
|
||||
// Проверка пароля: показываем модальное окно, если требуется пароль
|
||||
// Хост не должен видеть модальное окно пароля (проверяется на бэкенде)
|
||||
// Показываем независимо от авторизации - пароль проверяется первым
|
||||
useEffect(() => {
|
||||
if (requiresPassword && !isPasswordModalOpen && !loading && user) {
|
||||
// Проверяем, не является ли пользователь хостом
|
||||
// Если это хост, то requiresPassword не должно быть true (бэкенд должен разрешить доступ)
|
||||
setIsPasswordModalOpen(true);
|
||||
} else if (requiresPassword && !isPasswordModalOpen && !loading && !user) {
|
||||
// Если пользователь не авторизован, все равно показываем модальное окно
|
||||
// После авторизации проверим, является ли он хостом
|
||||
if (requiresPassword && !isPasswordModalOpen && !loading) {
|
||||
// Показывать модальное окно пароля независимо от авторизации
|
||||
setIsPasswordModalOpen(true);
|
||||
}
|
||||
}, [requiresPassword, isPasswordModalOpen, loading, user]);
|
||||
}, [requiresPassword, isPasswordModalOpen, loading]);
|
||||
|
||||
// Проверка авторизации и показ модального окна для ввода имени
|
||||
// Показывать только если НЕТ пароля - пароль приоритетнее
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user && room && !loading && !requiresPassword) {
|
||||
setIsNameModalOpen(true);
|
||||
|
|
@ -211,6 +212,37 @@ const RoomPage = () => {
|
|||
}
|
||||
}, [room, roomCode, navigate]);
|
||||
|
||||
// Применяем тему из gameStateUpdated/roomUpdate
|
||||
useEffect(() => {
|
||||
if (!roomCode) return;
|
||||
|
||||
const handleGameStateUpdated = (state) => {
|
||||
const currentThemeId = state.themeId || null;
|
||||
if (currentThemeId !== previousThemeIdRef.current) {
|
||||
previousThemeIdRef.current = currentThemeId;
|
||||
if (currentThemeId) {
|
||||
changeTheme(currentThemeId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Также проверяем тему из room при изменении
|
||||
if (room?.themeId) {
|
||||
const currentThemeId = room.themeId || null;
|
||||
if (currentThemeId !== previousThemeIdRef.current) {
|
||||
previousThemeIdRef.current = currentThemeId;
|
||||
if (currentThemeId) {
|
||||
changeTheme(currentThemeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socketService.on('gameStateUpdated', handleGameStateUpdated);
|
||||
return () => {
|
||||
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
||||
};
|
||||
}, [roomCode, room, changeTheme]);
|
||||
|
||||
const handleStartGame = () => {
|
||||
startGame();
|
||||
navigate(`/game/${roomCode}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue