This commit is contained in:
Dmitry 2026-01-10 23:49:42 +03:00
parent 6c32a26fc4
commit 1b9bab71be
35 changed files with 1704 additions and 240 deletions

View file

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

View file

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

View file

@ -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>
) : ( ) : (

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

View file

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

View file

@ -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,27 +254,29 @@ export function CreateAdminRoomDialog({
</Card> </Card>
{/* Theme Selection */} {/* Theme Selection */}
<div className="space-y-2"> {isThemesEnabled() && (
<Label htmlFor="themeId">Theme</Label> <div className="space-y-2">
<Select <Label htmlFor="themeId">Theme</Label>
value={formData.themeId || ''} <Select
onValueChange={(value) => value={formData.themeId || ''}
setFormData({ ...formData, themeId: value || undefined }) onValueChange={(value) =>
} setFormData({ ...formData, themeId: value || undefined })
> }
<SelectTrigger id="themeId"> >
<SelectValue placeholder="Default theme" /> <SelectTrigger id="themeId">
</SelectTrigger> <SelectValue placeholder="Default theme" />
<SelectContent> </SelectTrigger>
<SelectItem value="">Default theme</SelectItem> <SelectContent>
{themesData?.themes.map((theme) => ( <SelectItem value="">Default theme</SelectItem>
<SelectItem key={theme.id} value={theme.id}> {themesData?.themes.map((theme) => (
{theme.name} {theme.isPublic ? '(Public)' : '(Private)'} <SelectItem key={theme.id} value={theme.id}>
</SelectItem> {theme.name} {theme.isPublic ? '(Public)' : '(Private)'}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</div> </Select>
</div>
)}
{/* Question Pack */} {/* Question Pack */}
<div className="space-y-2"> <div className="space-y-2">
@ -304,24 +310,26 @@ export function CreateAdminRoomDialog({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center space-x-2"> {isThemesEnabled() && (
<Checkbox <div className="flex items-center space-x-2">
id="allowThemeChange" <Checkbox
checked={formData.uiControls?.allowThemeChange} id="allowThemeChange"
onCheckedChange={(checked) => checked={formData.uiControls?.allowThemeChange}
setFormData({ onCheckedChange={(checked) =>
...formData, setFormData({
uiControls: { ...formData,
...formData.uiControls, uiControls: {
allowThemeChange: checked as boolean, ...formData.uiControls,
}, allowThemeChange: checked as boolean,
}) },
} })
/> }
<Label htmlFor="allowThemeChange" className="cursor-pointer"> />
Allow theme change <Label htmlFor="allowThemeChange" className="cursor-pointer">
</Label> Allow theme change
</div> </Label>
</div>
)}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="allowPackChange" id="allowPackChange"

View file

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

View file

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

View 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,
}
}

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

View file

@ -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>
) : ( ) : (
<> <>
<Table> <DndContext
<TableHeader> sensors={sensors}
<TableRow> collisionDetection={closestCenter}
<TableHead>Preview</TableHead> onDragEnd={handleDragEnd}
<TableHead>Name</TableHead> >
<TableHead>Public</TableHead> <Table>
<TableHead>Creator</TableHead> <TableHeader>
<TableHead>Created</TableHead> <TableRow>
<TableHead>Actions</TableHead> <TableHead className="w-[100px]">Preview</TableHead>
</TableRow> <TableHead>Name</TableHead>
</TableHeader> <TableHead>Public</TableHead>
<TableBody> <TableHead>Default</TableHead>
{(data?.themes || []).map((theme) => ( <TableHead className="w-[80px]">Order</TableHead>
<TableRow key={theme.id}> <TableHead>Creator</TableHead>
<TableCell> <TableHead>Created</TableHead>
<div <TableHead className="w-[150px]">Actions</TableHead>
className="w-16 h-10 rounded-md border"
style={{
background: theme.colors.bgPrimary,
}}
>
<div
className="w-full h-full flex items-center justify-center text-xs font-bold"
style={{
color: theme.colors.accentPrimary,
textShadow: `0 0 4px ${theme.colors.textGlow}`,
}}
>
Aa
</div>
</div>
</TableCell>
<TableCell>
<div className="font-medium">{theme.name}</div>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
theme.isPublic
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{theme.isPublic ? 'Public' : 'Private'}
</span>
</TableCell>
<TableCell>{theme.creator?.name || 'Unknown'}</TableCell>
<TableCell>
{new Date(theme.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => openEditEditor(theme)}
title="Edit theme"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(theme)}
title="Delete theme"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> <SortableContext
items={data?.themes.map((t) => t.id) || []}
strategy={verticalListSortingStrategy}
>
{(data?.themes || []).map((theme) => (
<SortableRow
key={theme.id}
theme={theme}
onEdit={openEditEditor}
onDelete={handleDelete}
onSetDefault={handleSetDefault}
/>
))}
</SortableContext>
</TableBody>
</Table>
</DndContext>
{/* Pagination */} {/* Pagination */}
{data && data.totalPages > 1 && ( {data && data.totalPages > 1 && (

View file

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

View file

@ -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!');
} }

View file

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

View file

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

View file

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

View file

@ -0,0 +1,11 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class UpdateFeatureFlagDto {
@IsBoolean()
enabled: boolean;
@IsString()
@IsOptional()
description?: string;
}

View file

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

View file

@ -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' },
}); });
} }
} }

View file

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

View file

@ -0,0 +1,9 @@
import { IsArray, IsString, ArrayMinSize } from 'class-validator';
export class ReorderThemesDto {
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
themeIds: string[];
}

View file

@ -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;
// Начисляем очки // Начисляем очки текущему игроку (не тому, кто открыл ответ)
await this.prisma.participant.update({ if (room.currentPlayerId) {
where: { id: payload.participantId }, await this.prisma.participant.update({
data: { score: { increment: answer.points } } where: { id: room.currentPlayerId },
}); 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: {

View file

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

View file

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

View file

@ -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' },
],
}); });
} }

View file

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

View file

@ -32,52 +32,61 @@ 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()}`} borderColor: getPointsColor(answer.points),
onClick={onClick} background: `linear-gradient(135deg, ${getPointsColor(
disabled={isRevealed} answer.points
style={ )}20, ${getPointsColor(answer.points)}40)`,
isRevealed
? {
borderColor: getPointsColor(answer.points),
background: `linear-gradient(135deg, ${getPointsColor(
answer.points
)}20, ${getPointsColor(answer.points)}40)`,
}
: {}
} }
> : {}
{isRevealed ? (
<div className="answer-revealed-content"> const commonClassName = `answer-button ${getAnswerClass()}`
<span className="answer-text">{answer.text}</span>
<div className="answer-revealed-footer"> // Use button when hidden (clickable), div when revealed (not clickable, allows nested buttons)
<span if (!isRevealed) {
className="answer-points" return (
style={{ color: getPointsColor(answer.points) }} <button
> className={commonClassName}
{answer.points} onClick={onClick}
</span> style={commonStyle}
{roomId && questionId && answer.id && ( >
<VoicePlayer
roomId={roomId}
questionId={questionId}
contentType="answer"
answerId={answer.id}
showButton={true}
/>
)}
</div>
</div>
) : (
<span <span
className="answer-points-hidden" className="answer-points-hidden"
style={{ color: getPointsColor(answer.points) }} style={{ color: getPointsColor(answer.points) }}
> >
{answer.points} {answer.points}
</span> </span>
)} </button>
</button> )
}
return (
<div
className={commonClassName}
style={commonStyle}
>
<div className="answer-revealed-content">
<span className="answer-text">{answer.text}</span>
<div className="answer-revealed-footer">
<span
className="answer-points"
style={{ color: getPointsColor(answer.points) }}
>
{answer.points}
</span>
{roomId && questionId && answer.id && (
<VoicePlayer
roomId={roomId}
questionId={questionId}
contentType="answer"
answerId={answer.id}
showButton={true}
/>
)}
</div>
</div>
</div>
) )
} }

View file

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

View 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;
}
}

View 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

View file

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

View file

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

View file

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

View file

@ -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,45 +502,54 @@ const GamePage = () => {
)} )}
<div className="game-container"> <div className="game-container">
{gameState.questions.length === 0 && ( {gameState.status === 'FINISHED' ? (
<div className="no-questions-banner"> <GameFinishedScreen
<p> participants={gameState.participants}
Вопросы не загружены. playerScores={playerScores}
{isHost />
? ' Откройте управление вопросами, чтобы добавить вопросы.' ) : (
: ' Ожидайте, пока ведущий добавит вопросы.'} <>
</p> {gameState.questions.length === 0 && (
</div> <div className="no-questions-banner">
)} <p>
Вопросы не загружены.
{isHost
? ' Откройте управление вопросами, чтобы добавить вопросы.'
: ' Ожидайте, пока ведущий добавит вопросы.'}
</p>
</div>
)}
{isSpectator && ( {isSpectator && (
<div className="spectator-info" style={{ <div className="spectator-info" style={{
padding: '20px', padding: '20px',
margin: '20px', margin: '20px',
background: 'rgba(255, 215, 0, 0.1)', background: 'rgba(255, 215, 0, 0.1)',
border: '2px solid rgba(255, 215, 0, 0.3)', border: '2px solid rgba(255, 215, 0, 0.3)',
borderRadius: '12px', borderRadius: '12px',
textAlign: 'center', textAlign: 'center',
color: 'var(--text-primary)' color: 'var(--text-primary)'
}}> }}>
<p style={{ fontSize: '1.1rem', margin: 0 }}> <p style={{ fontSize: '1.1rem', margin: 0 }}>
👀 Вы в роли зрителя. Вы можете наблюдать за игрой, но не можете отвечать на вопросы. 👀 Вы в роли зрителя. Вы можете наблюдать за игрой, но не можете отвечать на вопросы.
</p> </p>
</div> </div>
)} )}
<Game <Game
currentQuestion={currentQuestion} currentQuestion={currentQuestion}
roomParticipants={gameState.participants} roomParticipants={gameState.participants}
roomId={gameState.roomId} roomId={gameState.roomId}
revealedAnswers={revealedForCurrentQ} revealedAnswers={revealedForCurrentQ}
playerScores={playerScores} playerScores={playerScores}
currentPlayerId={gameState.currentPlayerId} currentPlayerId={gameState.currentPlayerId}
onAnswerClick={canPerformActions ? handleAnswerClick : null} onAnswerClick={canPerformActions ? handleAnswerClick : null}
onPreviousQuestion={isHost && canGoPrev ? handlePrevQuestion : null} onPreviousQuestion={isHost && canGoPrev ? handlePrevQuestion : null}
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null} onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
onSelectPlayer={isHost ? handleSelectPlayer : null} onSelectPlayer={isHost ? handleSelectPlayer : null}
/> />
</>
)}
</div> </div>
{/* Modals */} {/* Modals */}

View file

@ -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}`);