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",
|
"name": "sto-k-odnomu-admin",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
|
@ -557,6 +560,59 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"axios": "^1.13.2",
|
"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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { useFeatureFlags } from '@/hooks/useFeatureFlags'
|
||||||
import LoginPage from '@/pages/LoginPage'
|
import LoginPage from '@/pages/LoginPage'
|
||||||
import DashboardPage from '@/pages/DashboardPage'
|
import DashboardPage from '@/pages/DashboardPage'
|
||||||
import PacksPage from '@/pages/PacksPage'
|
import PacksPage from '@/pages/PacksPage'
|
||||||
import UsersPage from '@/pages/UsersPage'
|
import UsersPage from '@/pages/UsersPage'
|
||||||
import ThemesPage from '@/pages/ThemesPage'
|
import ThemesPage from '@/pages/ThemesPage'
|
||||||
import RoomsPage from '@/pages/RoomsPage'
|
import RoomsPage from '@/pages/RoomsPage'
|
||||||
|
import FeatureFlagsPage from '@/pages/FeatureFlagsPage'
|
||||||
import Layout from '@/components/layout/Layout'
|
import Layout from '@/components/layout/Layout'
|
||||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
const { isThemesEnabled } = useFeatureFlags()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TokenRefreshProvider>
|
<TokenRefreshProvider>
|
||||||
|
|
@ -29,8 +32,9 @@ function App() {
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/packs" element={<PacksPage />} />
|
<Route path="/packs" element={<PacksPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
{isThemesEnabled() && <Route path="/themes" element={<ThemesPage />} />}
|
||||||
<Route path="/rooms" element={<RoomsPage />} />
|
<Route path="/rooms" element={<RoomsPage />} />
|
||||||
|
<Route path="/settings" element={<FeatureFlagsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</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
|
particleDurationMin?: number
|
||||||
particleDurationMax?: number
|
particleDurationMax?: number
|
||||||
particleInitialDelayMax?: 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 {
|
export interface Theme {
|
||||||
|
|
@ -42,6 +53,8 @@ export interface Theme {
|
||||||
icon?: string | null
|
icon?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
|
isDefault?: boolean
|
||||||
|
displayOrder?: number
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|
@ -59,6 +72,8 @@ export interface ThemePreview {
|
||||||
icon?: string | null
|
icon?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
|
isDefault?: boolean
|
||||||
|
displayOrder?: number
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|
@ -73,6 +88,8 @@ export interface CreateThemeDto {
|
||||||
icon?: string
|
icon?: string
|
||||||
description?: string
|
description?: string
|
||||||
isPublic?: boolean
|
isPublic?: boolean
|
||||||
|
isDefault?: boolean
|
||||||
|
displayOrder?: number
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +99,8 @@ export interface UpdateThemeDto {
|
||||||
icon?: string
|
icon?: string
|
||||||
description?: string
|
description?: string
|
||||||
isPublic?: boolean
|
isPublic?: boolean
|
||||||
|
isDefault?: boolean
|
||||||
|
displayOrder?: number
|
||||||
colors?: ThemeColors
|
colors?: ThemeColors
|
||||||
settings?: ThemeSettings
|
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 = {
|
export const DEFAULT_THEME_COLORS: ThemeColors = {
|
||||||
|
|
@ -261,4 +317,14 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
|
||||||
particleDurationMin: 7,
|
particleDurationMin: 7,
|
||||||
particleDurationMax: 10,
|
particleDurationMax: 10,
|
||||||
particleInitialDelayMax: 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 { usersApi } from '@/api/users'
|
||||||
import { themesApi } from '@/api/themes'
|
import { themesApi } from '@/api/themes'
|
||||||
import { packsApi } from '@/api/packs'
|
import { packsApi } from '@/api/packs'
|
||||||
|
import { useFeatureFlags } from '@/hooks/useFeatureFlags'
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -38,6 +39,8 @@ export function CreateAdminRoomDialog({
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: CreateAdminRoomDialogProps) {
|
}: CreateAdminRoomDialogProps) {
|
||||||
|
const { isThemesEnabled } = useFeatureFlags()
|
||||||
|
|
||||||
const [formData, setFormData] = useState<CreateAdminRoomDto>({
|
const [formData, setFormData] = useState<CreateAdminRoomDto>({
|
||||||
hostId: '',
|
hostId: '',
|
||||||
hostName: 'Ведущий',
|
hostName: 'Ведущий',
|
||||||
|
|
@ -69,6 +72,7 @@ export function CreateAdminRoomDialog({
|
||||||
const { data: themesData } = useQuery({
|
const { data: themesData } = useQuery({
|
||||||
queryKey: ['themes'],
|
queryKey: ['themes'],
|
||||||
queryFn: () => themesApi.getThemes({ page: 1, limit: 100 }),
|
queryFn: () => themesApi.getThemes({ page: 1, limit: 100 }),
|
||||||
|
enabled: isThemesEnabled(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: packsData } = useQuery({
|
const { data: packsData } = useQuery({
|
||||||
|
|
@ -250,6 +254,7 @@ export function CreateAdminRoomDialog({
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Theme Selection */}
|
{/* Theme Selection */}
|
||||||
|
{isThemesEnabled() && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="themeId">Theme</Label>
|
<Label htmlFor="themeId">Theme</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -271,6 +276,7 @@ export function CreateAdminRoomDialog({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Question Pack */}
|
{/* Question Pack */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -304,6 +310,7 @@ export function CreateAdminRoomDialog({
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{isThemesEnabled() && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="allowThemeChange"
|
id="allowThemeChange"
|
||||||
|
|
@ -322,6 +329,7 @@ export function CreateAdminRoomDialog({
|
||||||
Allow theme change
|
Allow theme change
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="allowPackChange"
|
id="allowPackChange"
|
||||||
|
|
|
||||||
|
|
@ -709,6 +709,107 @@ export function ThemeEditorDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { useFeatureFlags } from '@/hooks/useFeatureFlags'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -10,28 +11,43 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
DoorOpen
|
DoorOpen,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode
|
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) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
|
const { isThemesEnabled, flags } = useFeatureFlags()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
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 { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 {
|
import {
|
||||||
themesApi,
|
themesApi,
|
||||||
isThemesApiError,
|
isThemesApiError,
|
||||||
|
|
@ -33,10 +50,127 @@ import {
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
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 { ThemeEditorDialog } from '@/components/ThemeEditorDialog'
|
||||||
import { ThemeImportDialog } from '@/components/ThemeImportDialog'
|
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() {
|
export default function ThemesPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [page, setPage] = useState(1)
|
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 = () => {
|
const openCreateEditor = () => {
|
||||||
setEditingTheme(null)
|
setEditingTheme(null)
|
||||||
setImportedData(null)
|
setImportedData(null)
|
||||||
|
|
@ -253,87 +436,56 @@ export default function ThemesPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>All Themes ({data?.total || 0})</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">Loading themes...</div>
|
<div className="text-center py-8">Loading themes...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Preview</TableHead>
|
<TableHead className="w-[100px]">Preview</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Public</TableHead>
|
<TableHead>Public</TableHead>
|
||||||
|
<TableHead>Default</TableHead>
|
||||||
|
<TableHead className="w-[80px]">Order</TableHead>
|
||||||
<TableHead>Creator</TableHead>
|
<TableHead>Creator</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Created</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead className="w-[150px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
<SortableContext
|
||||||
|
items={data?.themes.map((t) => t.id) || []}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
{(data?.themes || []).map((theme) => (
|
{(data?.themes || []).map((theme) => (
|
||||||
<TableRow key={theme.id}>
|
<SortableRow
|
||||||
<TableCell>
|
key={theme.id}
|
||||||
<div
|
theme={theme}
|
||||||
className="w-16 h-10 rounded-md border"
|
onEdit={openEditEditor}
|
||||||
style={{
|
onDelete={handleDelete}
|
||||||
background: theme.colors.bgPrimary,
|
onSetDefault={handleSetDefault}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{data && data.totalPages > 1 && (
|
{data && data.totalPages > 1 && (
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,8 @@ model Theme {
|
||||||
icon String?
|
icon String?
|
||||||
description String?
|
description String?
|
||||||
isPublic Boolean @default(false)
|
isPublic Boolean @default(false)
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
displayOrder Int @default(0)
|
||||||
createdBy String
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
@ -201,4 +203,17 @@ model Theme {
|
||||||
|
|
||||||
creator User @relation(fields: [createdBy], references: [id])
|
creator User @relation(fields: [createdBy], references: [id])
|
||||||
rooms Room[]
|
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`);
|
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!');
|
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 { AdminThemesController } from './themes/admin-themes.controller';
|
||||||
import { AdminThemesService } from './themes/admin-themes.service';
|
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
|
// Guards
|
||||||
import { AdminAuthGuard } from './guards/admin-auth.guard';
|
import { AdminAuthGuard } from './guards/admin-auth.guard';
|
||||||
import { AdminGuard } from './guards/admin.guard';
|
import { AdminGuard } from './guards/admin.guard';
|
||||||
|
|
@ -61,6 +65,7 @@ import { AdminGuard } from './guards/admin.guard';
|
||||||
AdminAnalyticsController,
|
AdminAnalyticsController,
|
||||||
AdminGameHistoryController,
|
AdminGameHistoryController,
|
||||||
AdminThemesController,
|
AdminThemesController,
|
||||||
|
AdminFeatureFlagsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminAuthService,
|
AdminAuthService,
|
||||||
|
|
@ -70,6 +75,7 @@ import { AdminGuard } from './guards/admin.guard';
|
||||||
AdminAnalyticsService,
|
AdminAnalyticsService,
|
||||||
AdminGameHistoryService,
|
AdminGameHistoryService,
|
||||||
AdminThemesService,
|
AdminThemesService,
|
||||||
|
AdminFeatureFlagsService,
|
||||||
AdminAuthGuard,
|
AdminAuthGuard,
|
||||||
AdminGuard,
|
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 { ThemeFiltersDto } from './dto/theme-filters.dto';
|
||||||
import { CreateThemeDto } from './dto/create-theme.dto';
|
import { CreateThemeDto } from './dto/create-theme.dto';
|
||||||
import { UpdateThemeDto } from './dto/update-theme.dto';
|
import { UpdateThemeDto } from './dto/update-theme.dto';
|
||||||
|
import { ReorderThemesDto } from './dto/reorder-themes.dto';
|
||||||
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
||||||
import { AdminGuard } from '../guards/admin.guard';
|
import { AdminGuard } from '../guards/admin.guard';
|
||||||
|
|
||||||
|
|
@ -46,4 +47,14 @@ export class AdminThemesController {
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.adminThemesService.remove(id);
|
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,
|
name: true,
|
||||||
description: true,
|
description: true,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
isDefault: true,
|
||||||
|
displayOrder: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
settings: true,
|
settings: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
|
@ -46,7 +48,10 @@ export class AdminThemesService {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: [
|
||||||
|
{ displayOrder: 'asc' },
|
||||||
|
{ name: 'asc' },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
this.prisma.theme.count({ where }),
|
this.prisma.theme.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
@ -82,12 +87,32 @@ export class AdminThemesService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(createThemeDto: CreateThemeDto, createdBy: string) {
|
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({
|
return this.prisma.theme.create({
|
||||||
data: {
|
data: {
|
||||||
name: createThemeDto.name,
|
name: createThemeDto.name,
|
||||||
icon: createThemeDto.icon,
|
icon: createThemeDto.icon,
|
||||||
description: createThemeDto.description,
|
description: createThemeDto.description,
|
||||||
isPublic: createThemeDto.isPublic ?? true,
|
isPublic: createThemeDto.isPublic ?? true,
|
||||||
|
isDefault: createThemeDto.isDefault ?? false,
|
||||||
|
displayOrder,
|
||||||
createdBy,
|
createdBy,
|
||||||
colors: JSON.parse(JSON.stringify(createThemeDto.colors)) as Prisma.InputJsonValue,
|
colors: JSON.parse(JSON.stringify(createThemeDto.colors)) as Prisma.InputJsonValue,
|
||||||
settings: JSON.parse(JSON.stringify(createThemeDto.settings)) 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');
|
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 = {
|
const updateData: Prisma.ThemeUpdateInput = {
|
||||||
...(updateThemeDto.name !== undefined && { name: updateThemeDto.name }),
|
...(updateThemeDto.name !== undefined && { name: updateThemeDto.name }),
|
||||||
...(updateThemeDto.icon !== undefined && { icon: updateThemeDto.icon }),
|
...(updateThemeDto.icon !== undefined && { icon: updateThemeDto.icon }),
|
||||||
|
|
@ -120,6 +153,12 @@ export class AdminThemesService {
|
||||||
...(updateThemeDto.isPublic !== undefined && {
|
...(updateThemeDto.isPublic !== undefined && {
|
||||||
isPublic: updateThemeDto.isPublic,
|
isPublic: updateThemeDto.isPublic,
|
||||||
}),
|
}),
|
||||||
|
...(updateThemeDto.isDefault !== undefined && {
|
||||||
|
isDefault: updateThemeDto.isDefault,
|
||||||
|
}),
|
||||||
|
...(updateThemeDto.displayOrder !== undefined && {
|
||||||
|
displayOrder: updateThemeDto.displayOrder,
|
||||||
|
}),
|
||||||
...(updateThemeDto.colors && {
|
...(updateThemeDto.colors && {
|
||||||
colors: JSON.parse(
|
colors: JSON.parse(
|
||||||
JSON.stringify(updateThemeDto.colors),
|
JSON.stringify(updateThemeDto.colors),
|
||||||
|
|
@ -173,8 +212,81 @@ export class AdminThemesService {
|
||||||
description: true,
|
description: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
settings: 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()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
particleInitialDelayMax?: 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 {
|
export class CreateThemeDto {
|
||||||
|
|
@ -129,6 +171,15 @@ export class CreateThemeDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => ThemeColorsDto)
|
@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);
|
currentRevealed.push(payload.answerId);
|
||||||
revealed[payload.questionId] = currentRevealed;
|
revealed[payload.questionId] = currentRevealed;
|
||||||
|
|
||||||
// Начисляем очки
|
// Начисляем очки текущему игроку (не тому, кто открыл ответ)
|
||||||
|
if (room.currentPlayerId) {
|
||||||
await this.prisma.participant.update({
|
await this.prisma.participant.update({
|
||||||
where: { id: payload.participantId },
|
where: { id: room.currentPlayerId },
|
||||||
data: { score: { increment: answer.points } }
|
data: { score: { increment: answer.points } }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Сохраняем revealedAnswers
|
// Сохраняем revealedAnswers
|
||||||
await this.prisma.room.update({
|
await this.prisma.room.update({
|
||||||
|
|
@ -259,9 +261,9 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
data: { revealedAnswers: revealed as Prisma.InputJsonValue }
|
data: { revealedAnswers: revealed as Prisma.InputJsonValue }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Определяем следующего игрока
|
// Определяем следующего игрока на основе текущего игрока
|
||||||
const participants = room.participants;
|
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 nextIdx = (currentIdx + 1) % participants.length;
|
||||||
const nextPlayerId = participants[nextIdx]?.id;
|
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({
|
const room = (await this.prisma.room.findUnique({
|
||||||
where: { code: roomCode },
|
where: { code: roomCode },
|
||||||
include: {
|
include: {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,6 @@ import { RoomPackModule } from '../room-pack/room-pack.module';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => RoomsModule), RoomPackModule],
|
imports: [forwardRef(() => RoomsModule), RoomPackModule],
|
||||||
providers: [GameGateway, RoomEventsService],
|
providers: [GameGateway, RoomEventsService],
|
||||||
exports: [RoomEventsService],
|
exports: [RoomEventsService, GameGateway],
|
||||||
})
|
})
|
||||||
export class GameModule {}
|
export class GameModule {}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException,
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { customAlphabet } from 'nanoid';
|
import { customAlphabet } from 'nanoid';
|
||||||
import { RoomEventsService } from '../game/room-events.service';
|
import { RoomEventsService } from '../game/room-events.service';
|
||||||
|
import { GameGateway } from '../game/game.gateway';
|
||||||
import { RoomPackService } from '../room-pack/room-pack.service';
|
import { RoomPackService } from '../room-pack/room-pack.service';
|
||||||
|
|
||||||
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
|
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
|
||||||
|
|
@ -12,6 +13,8 @@ export class RoomsService {
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
@Inject(forwardRef(() => RoomEventsService))
|
@Inject(forwardRef(() => RoomEventsService))
|
||||||
private roomEventsService: RoomEventsService,
|
private roomEventsService: RoomEventsService,
|
||||||
|
@Inject(forwardRef(() => GameGateway))
|
||||||
|
private gameGateway: GameGateway,
|
||||||
private roomPackService: RoomPackService,
|
private roomPackService: RoomPackService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -139,12 +142,16 @@ export class RoomsService {
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
|
roomPack: true,
|
||||||
|
theme: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отправляем событие roomUpdate всем клиентам в комнате
|
// Отправляем событие roomUpdate всем клиентам в комнате
|
||||||
if (updatedRoom) {
|
if (updatedRoom) {
|
||||||
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
||||||
|
// Также отправляем gameStateUpdated через broadcastFullState
|
||||||
|
await this.gameGateway.broadcastFullState(updatedRoom.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
return participant;
|
return participant;
|
||||||
|
|
@ -231,6 +238,8 @@ export class RoomsService {
|
||||||
// Отправляем событие roomUpdate всем клиентам в комнате
|
// Отправляем событие roomUpdate всем клиентам в комнате
|
||||||
if (updatedRoom) {
|
if (updatedRoom) {
|
||||||
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
||||||
|
// Также отправляем gameStateUpdated через broadcastFullState
|
||||||
|
await this.gameGateway.broadcastFullState(updatedRoom.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
return participant;
|
return participant;
|
||||||
|
|
@ -286,11 +295,14 @@ export class RoomsService {
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
roomPack: true,
|
roomPack: true,
|
||||||
|
theme: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отправляем обновление через WebSocket
|
// Отправляем обновление через WebSocket
|
||||||
this.roomEventsService.emitRoomUpdate(room.code, room);
|
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||||
|
// Также отправляем gameStateUpdated через broadcastFullState
|
||||||
|
await this.gameGateway.broadcastFullState(room.code);
|
||||||
|
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
@ -312,11 +324,15 @@ export class RoomsService {
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
roomPack: true,
|
roomPack: true,
|
||||||
|
theme: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
this.roomEventsService.emitRoomPackUpdated(room.code, room);
|
this.roomEventsService.emitRoomPackUpdated(room.code, room);
|
||||||
|
// Также отправляем gameStateUpdated через broadcastFullState для синхронизации вопросов
|
||||||
|
// (если метод вызывается напрямую через REST API, а не через WebSocket)
|
||||||
|
await this.gameGateway.broadcastFullState(room.code);
|
||||||
}
|
}
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
@ -354,10 +370,14 @@ export class RoomsService {
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
|
roomPack: true,
|
||||||
|
theme: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.roomEventsService.emitRoomUpdate(room.code, room);
|
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||||
|
// Также отправляем gameStateUpdated через broadcastFullState
|
||||||
|
await this.gameGateway.broadcastFullState(room.code);
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,11 +448,16 @@ export class RoomsService {
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
|
roomPack: true,
|
||||||
|
theme: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
this.roomEventsService.emitPlayerKicked(room.code, { participantId, room });
|
this.roomEventsService.emitPlayerKicked(room.code, { participantId, room });
|
||||||
|
// Также отправляем gameStateUpdated через broadcastFullState для синхронизации участников
|
||||||
|
// (если метод вызывается напрямую через REST API, а не через WebSocket)
|
||||||
|
await this.gameGateway.broadcastFullState(room.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
return room;
|
return room;
|
||||||
|
|
@ -521,11 +546,15 @@ export class RoomsService {
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
|
roomPack: true,
|
||||||
|
theme: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
this.roomEventsService.emitRoomUpdate(room.code, room);
|
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||||
|
// Также отправляем gameStateUpdated через broadcastFullState
|
||||||
|
await this.gameGateway.broadcastFullState(room.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
return room;
|
return room;
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,13 @@ export class ThemesController {
|
||||||
description: true,
|
description: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
settings: 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: 3px solid rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: clamp(8px, 1.5vh, 20px) clamp(12px, 1.5vw, 20px);
|
padding: clamp(8px, 1.5vh, 20px) clamp(12px, 1.5vw, 20px);
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -20,6 +19,11 @@
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.05);
|
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 для узких кнопок */
|
/* Горизонтальный layout для узких кнопок */
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.answer-button {
|
.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);
|
transform: translateY(-5px);
|
||||||
box-shadow: 0 8px 25px rgba(255, 215, 0, 0.4);
|
box-shadow: 0 8px 25px rgba(255, 215, 0, 0.4);
|
||||||
border-color: rgba(255, 215, 0, 0.6);
|
border-color: rgba(255, 215, 0, 0.6);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-button:active:not(:disabled) {
|
.answer-button:not(.answer-revealed):active {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-button:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-hidden {
|
.answer-hidden {
|
||||||
animation: pulse 2s ease-in-out infinite;
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +94,8 @@
|
||||||
flex-shrink: 0;
|
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;
|
opacity: 1;
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,7 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
||||||
}
|
}
|
||||||
}, [isRevealed, autoPlayAnswers, roomId, questionId, answer.id, speak])
|
}, [isRevealed, autoPlayAnswers, roomId, questionId, answer.id, speak])
|
||||||
|
|
||||||
return (
|
const commonStyle = isRevealed
|
||||||
<button
|
|
||||||
className={`answer-button ${getAnswerClass()}`}
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={isRevealed}
|
|
||||||
style={
|
|
||||||
isRevealed
|
|
||||||
? {
|
? {
|
||||||
borderColor: getPointsColor(answer.points),
|
borderColor: getPointsColor(answer.points),
|
||||||
background: `linear-gradient(135deg, ${getPointsColor(
|
background: `linear-gradient(135deg, ${getPointsColor(
|
||||||
|
|
@ -46,9 +40,32 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
||||||
)}20, ${getPointsColor(answer.points)}40)`,
|
)}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">
|
<div className="answer-revealed-content">
|
||||||
<span className="answer-text">{answer.text}</span>
|
<span className="answer-text">{answer.text}</span>
|
||||||
<div className="answer-revealed-footer">
|
<div className="answer-revealed-footer">
|
||||||
|
|
@ -69,15 +86,7 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<span
|
|
||||||
className="answer-points-hidden"
|
|
||||||
style={{ color: getPointsColor(answer.points) }}
|
|
||||||
>
|
|
||||||
{answer.points}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
margin-top: clamp(5px, 1vh, 10px);
|
margin-top: clamp(5px, 1vh, 10px);
|
||||||
margin-bottom: clamp(5px, 1vh, 15px);
|
margin-bottom: clamp(5px, 1vh, 15px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: clamp(5px, 1vh, 10px);
|
gap: clamp(5px, 1vh, 10px);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
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 = [],
|
availablePacks = [],
|
||||||
onStartGame,
|
onStartGame,
|
||||||
onEndGame,
|
onEndGame,
|
||||||
|
onRestartGame,
|
||||||
onNextQuestion,
|
onNextQuestion,
|
||||||
onPreviousQuestion,
|
onPreviousQuestion,
|
||||||
onRevealAnswer,
|
onRevealAnswer,
|
||||||
|
|
@ -904,6 +905,15 @@ const GameManagementModal = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{gameStatus === 'FINISHED' && onRestartGame && (
|
||||||
|
<button
|
||||||
|
className="mgmt-button start-button"
|
||||||
|
onClick={onRestartGame}
|
||||||
|
>
|
||||||
|
🔄 Перезапустить игру
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Управление ответами - показывается только во время активной игры */}
|
{/* Управление ответами - показывается только во время активной игры */}
|
||||||
{gameStatus === 'PLAYING' && currentQuestion && (
|
{gameStatus === 'PLAYING' && currentQuestion && (
|
||||||
<div className="answers-control-section">
|
<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
|
// Set default theme if no theme is selected or current theme is not found
|
||||||
setCurrentTheme((prevTheme) => {
|
setCurrentTheme((prevTheme) => {
|
||||||
if (!prevTheme || !data.find((t) => t.id === 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) {
|
if (defaultTheme) {
|
||||||
localStorage.setItem('app-theme', defaultTheme.id);
|
localStorage.setItem('app-theme', defaultTheme.id);
|
||||||
return defaultTheme.id;
|
return defaultTheme.id;
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,16 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
setParticipants(response.data.participants || []);
|
setParticipants(response.data.participants || []);
|
||||||
setError(null);
|
setError(null);
|
||||||
setRequiresPassword(false);
|
setRequiresPassword(false);
|
||||||
|
|
||||||
|
// ✅ Подключаться к WebSocket только после успешной загрузки комнаты
|
||||||
|
socketService.connect();
|
||||||
|
socketService.joinRoom(roomCode, user?.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Проверяем, требуется ли пароль (401 Unauthorized)
|
// Проверяем, требуется ли пароль (401 Unauthorized)
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
setRequiresPassword(true);
|
setRequiresPassword(true);
|
||||||
setError('Room password required');
|
setError('Room password required');
|
||||||
|
// ❌ НЕ подключаться к WebSocket, если требуется пароль
|
||||||
} else {
|
} else {
|
||||||
setError(err.response?.data?.message || err.message);
|
setError(err.response?.data?.message || err.message);
|
||||||
setRequiresPassword(false);
|
setRequiresPassword(false);
|
||||||
|
|
@ -42,13 +47,7 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
|
|
||||||
fetchRoom();
|
fetchRoom();
|
||||||
|
|
||||||
// Connect to WebSocket
|
// Listen for room updates (регистрируются всегда, но не подключаются если requiresPassword)
|
||||||
socketService.connect();
|
|
||||||
|
|
||||||
// Join the room via WebSocket
|
|
||||||
socketService.joinRoom(roomCode, user?.id);
|
|
||||||
|
|
||||||
// Listen for room updates
|
|
||||||
const handleRoomUpdate = (updatedRoom) => {
|
const handleRoomUpdate = (updatedRoom) => {
|
||||||
setRoom(updatedRoom);
|
setRoom(updatedRoom);
|
||||||
setParticipants(updatedRoom.participants || []);
|
setParticipants(updatedRoom.participants || []);
|
||||||
|
|
@ -159,6 +158,11 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
setParticipants(response.data.participants || []);
|
setParticipants(response.data.participants || []);
|
||||||
setError(null);
|
setError(null);
|
||||||
setRequiresPassword(false);
|
setRequiresPassword(false);
|
||||||
|
|
||||||
|
// ✅ После успешной загрузки комнаты с паролем подключаемся к WebSocket
|
||||||
|
socketService.connect();
|
||||||
|
socketService.joinRoom(roomCode, user?.id);
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { questionsApi, roomsApi } from '../services/api';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import socketService from '../services/socket';
|
import socketService from '../services/socket';
|
||||||
import Game from '../components/Game';
|
import Game from '../components/Game';
|
||||||
|
import GameFinishedScreen from '../components/GameFinishedScreen';
|
||||||
import QRModal from '../components/QRModal';
|
import QRModal from '../components/QRModal';
|
||||||
import GameManagementModal from '../components/GameManagementModal';
|
import GameManagementModal from '../components/GameManagementModal';
|
||||||
import ThemeSwitcher from '../components/ThemeSwitcher';
|
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||||
|
|
@ -501,6 +502,13 @@ const GamePage = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="game-container">
|
<div className="game-container">
|
||||||
|
{gameState.status === 'FINISHED' ? (
|
||||||
|
<GameFinishedScreen
|
||||||
|
participants={gameState.participants}
|
||||||
|
playerScores={playerScores}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{gameState.questions.length === 0 && (
|
{gameState.questions.length === 0 && (
|
||||||
<div className="no-questions-banner">
|
<div className="no-questions-banner">
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -540,6 +548,8 @@ const GamePage = () => {
|
||||||
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
|
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
|
||||||
onSelectPlayer={isHost ? handleSelectPlayer : null}
|
onSelectPlayer={isHost ? handleSelectPlayer : null}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* 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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { useRoom } from '../hooks/useRoom';
|
import { useRoom } from '../hooks/useRoom';
|
||||||
import { questionsApi } from '../services/api';
|
import { questionsApi } from '../services/api';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
@ -15,6 +16,10 @@ const RoomPage = () => {
|
||||||
const { roomCode } = useParams();
|
const { roomCode } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, loginAnonymous, loading: authLoading } = useAuth();
|
const { user, loginAnonymous, loading: authLoading } = useAuth();
|
||||||
|
const { changeTheme } = useTheme();
|
||||||
|
|
||||||
|
// Храним предыдущий themeId комнаты для отслеживания изменений
|
||||||
|
const previousThemeIdRef = useRef(null);
|
||||||
|
|
||||||
// Callback для автоматической навигации при старте игры
|
// Callback для автоматической навигации при старте игры
|
||||||
const handleGameStartedEvent = useCallback(() => {
|
const handleGameStartedEvent = useCallback(() => {
|
||||||
|
|
@ -69,20 +74,16 @@ const RoomPage = () => {
|
||||||
}, [roomCode]);
|
}, [roomCode]);
|
||||||
|
|
||||||
// Проверка пароля: показываем модальное окно, если требуется пароль
|
// Проверка пароля: показываем модальное окно, если требуется пароль
|
||||||
// Хост не должен видеть модальное окно пароля (проверяется на бэкенде)
|
// Показываем независимо от авторизации - пароль проверяется первым
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requiresPassword && !isPasswordModalOpen && !loading && user) {
|
if (requiresPassword && !isPasswordModalOpen && !loading) {
|
||||||
// Проверяем, не является ли пользователь хостом
|
// Показывать модальное окно пароля независимо от авторизации
|
||||||
// Если это хост, то requiresPassword не должно быть true (бэкенд должен разрешить доступ)
|
|
||||||
setIsPasswordModalOpen(true);
|
|
||||||
} else if (requiresPassword && !isPasswordModalOpen && !loading && !user) {
|
|
||||||
// Если пользователь не авторизован, все равно показываем модальное окно
|
|
||||||
// После авторизации проверим, является ли он хостом
|
|
||||||
setIsPasswordModalOpen(true);
|
setIsPasswordModalOpen(true);
|
||||||
}
|
}
|
||||||
}, [requiresPassword, isPasswordModalOpen, loading, user]);
|
}, [requiresPassword, isPasswordModalOpen, loading]);
|
||||||
|
|
||||||
// Проверка авторизации и показ модального окна для ввода имени
|
// Проверка авторизации и показ модального окна для ввода имени
|
||||||
|
// Показывать только если НЕТ пароля - пароль приоритетнее
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user && room && !loading && !requiresPassword) {
|
if (!authLoading && !user && room && !loading && !requiresPassword) {
|
||||||
setIsNameModalOpen(true);
|
setIsNameModalOpen(true);
|
||||||
|
|
@ -211,6 +212,37 @@ const RoomPage = () => {
|
||||||
}
|
}
|
||||||
}, [room, roomCode, navigate]);
|
}, [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 = () => {
|
const handleStartGame = () => {
|
||||||
startGame();
|
startGame();
|
||||||
navigate(`/game/${roomCode}`);
|
navigate(`/game/${roomCode}`);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue