From 1b9bab71beb1bd71081f3c926786b7883b87db97 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 10 Jan 2026 23:49:42 +0300 Subject: [PATCH] stiff --- admin/package-lock.json | 56 ++++ admin/package.json | 3 + admin/src/App.tsx | 6 +- admin/src/api/featureFlags.ts | 106 ++++++ admin/src/api/themes.ts | 66 ++++ .../src/components/CreateAdminRoomDialog.tsx | 86 ++--- admin/src/components/ThemeEditorDialog.tsx | 101 ++++++ admin/src/components/layout/Layout.tsx | 36 ++- admin/src/hooks/useFeatureFlags.ts | 59 ++++ admin/src/pages/FeatureFlagsPage.tsx | 115 +++++++ admin/src/pages/ThemesPage.tsx | 302 +++++++++++++----- backend/prisma/schema.prisma | 15 + backend/prisma/seed.ts | 30 ++ backend/src/admin/admin.module.ts | 6 + .../admin-feature-flags.controller.ts | 34 ++ .../admin-feature-flags.service.ts | 64 ++++ .../dto/update-feature-flag.dto.ts | 11 + .../admin/themes/admin-themes.controller.ts | 11 + .../src/admin/themes/admin-themes.service.ts | 116 ++++++- .../src/admin/themes/dto/create-theme.dto.ts | 51 +++ .../admin/themes/dto/reorder-themes.dto.ts | 9 + backend/src/game/game.gateway.ts | 18 +- backend/src/game/game.module.ts | 2 +- backend/src/rooms/rooms.service.ts | 29 ++ backend/src/themes/themes.controller.ts | 7 +- src/components/Answer.css | 18 +- src/components/Answer.jsx | 87 ++--- src/components/Game.css | 3 +- src/components/GameFinishedScreen.css | 208 ++++++++++++ src/components/GameFinishedScreen.jsx | 122 +++++++ src/components/GameManagementModal.jsx | 10 + src/context/ThemeContext.jsx | 3 +- src/hooks/useRoom.js | 18 +- src/pages/GamePage.jsx | 84 ++--- src/pages/RoomPage.jsx | 52 ++- 35 files changed, 1704 insertions(+), 240 deletions(-) create mode 100644 admin/src/api/featureFlags.ts create mode 100644 admin/src/hooks/useFeatureFlags.ts create mode 100644 admin/src/pages/FeatureFlagsPage.tsx create mode 100644 backend/src/admin/feature-flags/admin-feature-flags.controller.ts create mode 100644 backend/src/admin/feature-flags/admin-feature-flags.service.ts create mode 100644 backend/src/admin/feature-flags/dto/update-feature-flag.dto.ts create mode 100644 backend/src/admin/themes/dto/reorder-themes.dto.ts create mode 100644 src/components/GameFinishedScreen.css create mode 100644 src/components/GameFinishedScreen.jsx diff --git a/admin/package-lock.json b/admin/package-lock.json index 02b0ad1..5f94b86 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -8,6 +8,9 @@ "name": "sto-k-odnomu-admin", "version": "0.0.1", "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -557,6 +560,59 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", diff --git a/admin/package.json b/admin/package.json index f6d8f59..741cd77 100644 --- a/admin/package.json +++ b/admin/package.json @@ -22,6 +22,9 @@ "@tanstack/react-query": "^5.90.11", "autoprefixer": "^10.4.22", "axios": "^1.13.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.555.0", diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 00bc34e..771d4ab 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -1,16 +1,19 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from '@/stores/authStore' +import { useFeatureFlags } from '@/hooks/useFeatureFlags' import LoginPage from '@/pages/LoginPage' import DashboardPage from '@/pages/DashboardPage' import PacksPage from '@/pages/PacksPage' import UsersPage from '@/pages/UsersPage' import ThemesPage from '@/pages/ThemesPage' import RoomsPage from '@/pages/RoomsPage' +import FeatureFlagsPage from '@/pages/FeatureFlagsPage' import Layout from '@/components/layout/Layout' import { TokenRefreshProvider } from '@/components/TokenRefreshProvider' function App() { const { isAuthenticated } = useAuthStore() + const { isThemesEnabled } = useFeatureFlags() return ( @@ -29,8 +32,9 @@ function App() { } /> } /> } /> - } /> + {isThemesEnabled() && } />} } /> + } /> ) : ( diff --git a/admin/src/api/featureFlags.ts b/admin/src/api/featureFlags.ts new file mode 100644 index 0000000..a998bb1 --- /dev/null +++ b/admin/src/api/featureFlags.ts @@ -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 => { + 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 => { + 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 => { + 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 + ) + } + }, +} + diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts index e3658af..fec2e16 100644 --- a/admin/src/api/themes.ts +++ b/admin/src/api/themes.ts @@ -34,6 +34,17 @@ export interface ThemeSettings { particleDurationMin?: number particleDurationMax?: number particleInitialDelayMax?: number + // Finish Screen Settings + finishScreenTitle?: string + finishScreenSubtitle?: string + finishScreenBgColor?: string + finishScreenCardBg?: string + finishScreenTop3Enabled?: boolean + finishScreenFirstPlaceColor?: string + finishScreenSecondPlaceColor?: string + finishScreenThirdPlaceColor?: string + finishScreenAnimationEnabled?: boolean + finishScreenAnimationDelay?: number } export interface Theme { @@ -42,6 +53,8 @@ export interface Theme { icon?: string | null description?: string | null isPublic: boolean + isDefault?: boolean + displayOrder?: number colors: ThemeColors settings: ThemeSettings createdAt: string @@ -59,6 +72,8 @@ export interface ThemePreview { icon?: string | null description?: string | null isPublic: boolean + isDefault?: boolean + displayOrder?: number colors: ThemeColors settings: ThemeSettings createdAt: string @@ -73,6 +88,8 @@ export interface CreateThemeDto { icon?: string description?: string isPublic?: boolean + isDefault?: boolean + displayOrder?: number colors: ThemeColors settings: ThemeSettings } @@ -82,6 +99,8 @@ export interface UpdateThemeDto { icon?: string description?: string isPublic?: boolean + isDefault?: boolean + displayOrder?: number colors?: ThemeColors settings?: ThemeSettings } @@ -226,6 +245,43 @@ export const themesApi = { ) } }, + + setDefaultTheme: async (themeId: string): Promise => { + 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 => { + try { + const response = await adminApiClient.patch('/api/admin/themes/reorder', { + themeIds, + }) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + throw createThemesApiError( + axiosError.response?.data?.message || 'Failed to reorder themes', + axiosError.response?.status, + error + ) + } + }, } export const DEFAULT_THEME_COLORS: ThemeColors = { @@ -261,4 +317,14 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = { particleDurationMin: 7, particleDurationMax: 10, particleInitialDelayMax: 10, + finishScreenTitle: 'Игра завершена!', + finishScreenSubtitle: '', + finishScreenBgColor: 'rgba(0, 0, 0, 0.5)', + finishScreenCardBg: 'rgba(255, 255, 255, 0.15)', + finishScreenTop3Enabled: true, + finishScreenFirstPlaceColor: '#ffd700', + finishScreenSecondPlaceColor: '#c0c0c0', + finishScreenThirdPlaceColor: '#cd7f32', + finishScreenAnimationEnabled: true, + finishScreenAnimationDelay: 100, } diff --git a/admin/src/components/CreateAdminRoomDialog.tsx b/admin/src/components/CreateAdminRoomDialog.tsx index dd44cb5..19b7cbf 100644 --- a/admin/src/components/CreateAdminRoomDialog.tsx +++ b/admin/src/components/CreateAdminRoomDialog.tsx @@ -5,6 +5,7 @@ import { roomsApi, type CreateAdminRoomDto } from '@/api/rooms' import { usersApi } from '@/api/users' import { themesApi } from '@/api/themes' import { packsApi } from '@/api/packs' +import { useFeatureFlags } from '@/hooks/useFeatureFlags' import type { AxiosError } from 'axios' import { Dialog, @@ -38,6 +39,8 @@ export function CreateAdminRoomDialog({ onClose, onSuccess, }: CreateAdminRoomDialogProps) { + const { isThemesEnabled } = useFeatureFlags() + const [formData, setFormData] = useState({ hostId: '', hostName: 'Ведущий', @@ -69,6 +72,7 @@ export function CreateAdminRoomDialog({ const { data: themesData } = useQuery({ queryKey: ['themes'], queryFn: () => themesApi.getThemes({ page: 1, limit: 100 }), + enabled: isThemesEnabled(), }) const { data: packsData } = useQuery({ @@ -250,27 +254,29 @@ export function CreateAdminRoomDialog({ {/* Theme Selection */} -
- - -
+ {isThemesEnabled() && ( +
+ + +
+ )} {/* Question Pack */}
@@ -304,24 +310,26 @@ export function CreateAdminRoomDialog({ -
- - setFormData({ - ...formData, - uiControls: { - ...formData.uiControls, - allowThemeChange: checked as boolean, - }, - }) - } - /> - -
+ {isThemesEnabled() && ( +
+ + setFormData({ + ...formData, + uiControls: { + ...formData.uiControls, + allowThemeChange: checked as boolean, + }, + }) + } + /> + +
+ )}
+ + {/* Finish Screen Section */} +
+

Finish Screen (Экран завершения)

+
+
+ + updateSetting('finishScreenTitle', e.target.value)} + placeholder="Игра завершена!" + /> +

+ Заголовок экрана завершения игры +

+
+
+ + updateSetting('finishScreenSubtitle', e.target.value)} + placeholder="Победитель игры" + /> +

+ Подзаголовок экрана завершения (опционально) +

+
+ updateSetting('finishScreenBgColor', v)} + description="Фон экрана завершения. Может отличаться от основного фона темы" + /> + updateSetting('finishScreenCardBg', v)} + description="Фон карточек игроков на экране завершения" + /> +
+ updateSetting('finishScreenTop3Enabled', checked)} + /> + +
+
+ updateSetting('finishScreenAnimationEnabled', checked)} + /> + +
+ updateSetting('finishScreenFirstPlaceColor', v)} + description="Цвет для 1-го места (золотой)" + /> + updateSetting('finishScreenSecondPlaceColor', v)} + description="Цвет для 2-го места (серебряный)" + /> + updateSetting('finishScreenThirdPlaceColor', v)} + description="Цвет для 3-го места (бронзовый)" + /> +
+ + { + const value = parseInt(e.target.value, 10) + if (!isNaN(value) && value >= 0) { + updateSetting('finishScreenAnimationDelay', value) + } + }} + /> +

+ Задержка между появлением карточек игроков в миллисекундах +

+
+
+
diff --git a/admin/src/components/layout/Layout.tsx b/admin/src/components/layout/Layout.tsx index 1327124..22dd4fa 100644 --- a/admin/src/components/layout/Layout.tsx +++ b/admin/src/components/layout/Layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '@/stores/authStore' +import { useFeatureFlags } from '@/hooks/useFeatureFlags' import { Button } from '@/components/ui/button' import { LayoutDashboard, @@ -10,28 +11,43 @@ import { LogOut, Menu, X, - DoorOpen + DoorOpen, + Settings, } from 'lucide-react' -import { useState } from 'react' +import { useState, useMemo } from 'react' interface LayoutProps { children: ReactNode } -const navigation = [ - { name: 'Dashboard', href: '/', icon: LayoutDashboard }, - { name: 'Packs', href: '/packs', icon: Package }, - { name: 'Users', href: '/users', icon: Users }, - { name: 'Themes', href: '/themes', icon: Palette }, - { name: 'Rooms', href: '/rooms', icon: DoorOpen }, -] - export default function Layout({ children }: LayoutProps) { const navigate = useNavigate() const location = useLocation() const { user, logout } = useAuthStore() + const { isThemesEnabled, flags } = useFeatureFlags() const [sidebarOpen, setSidebarOpen] = useState(false) + const navigation = useMemo(() => { + const baseNavigation = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Packs', href: '/packs', icon: Package }, + { name: 'Users', href: '/users', icon: Users }, + { name: 'Rooms', href: '/rooms', icon: DoorOpen }, + { name: 'Settings', href: '/settings', icon: Settings }, + ] + + if (isThemesEnabled()) { + const themesIndex = baseNavigation.findIndex((item) => item.name === 'Users') + baseNavigation.splice(themesIndex + 1, 0, { + name: 'Themes', + href: '/themes', + icon: Palette, + }) + } + + return baseNavigation + }, [isThemesEnabled, flags]) + const handleLogout = () => { logout() navigate('/login') diff --git a/admin/src/hooks/useFeatureFlags.ts b/admin/src/hooks/useFeatureFlags.ts new file mode 100644 index 0000000..70d358c --- /dev/null +++ b/admin/src/hooks/useFeatureFlags.ts @@ -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({ + 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, + } +} + diff --git a/admin/src/pages/FeatureFlagsPage.tsx b/admin/src/pages/FeatureFlagsPage.tsx new file mode 100644 index 0000000..e0d3aaf --- /dev/null +++ b/admin/src/pages/FeatureFlagsPage.tsx @@ -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 ( +
+
+

Feature Flags

+

Error loading feature flags

+
+ + +

Failed to load feature flags. Please try again later.

+
+
+
+ ) + } + + return ( +
+
+

Feature Flags

+

+ Manage global feature flags to enable or disable functionality across the admin panel +

+
+ + {isLoading ? ( + + +
Loading feature flags...
+
+
+ ) : ( + + + Available Features + + Toggle features on or off to control their visibility and availability + + + + {/* Themes Feature Flag */} +
+
+ +

+ {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.'} +

+
+
+ handleToggle('themesEnabled', checked as boolean)} + disabled={isUpdating} + /> +
+
+ + {/* Voice Mode Feature Flag */} +
+
+ +

+ {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.'} +

+
+
+ handleToggle('voiceModeEnabled', checked as boolean)} + disabled={isUpdating} + /> +
+
+ + {flags && flags.length === 0 && ( +
+ No feature flags available +
+ )} +
+
+ )} +
+ ) +} + diff --git a/admin/src/pages/ThemesPage.tsx b/admin/src/pages/ThemesPage.tsx index e306f28..6f2dad6 100644 --- a/admin/src/pages/ThemesPage.tsx +++ b/admin/src/pages/ThemesPage.tsx @@ -1,6 +1,23 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { themesApi, isThemesApiError, @@ -33,10 +50,127 @@ import { } from '@/components/ui/alert-dialog' import { Label } from '@/components/ui/label' import { Checkbox } from '@/components/ui/checkbox' -import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload } from 'lucide-react' +import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, Star, GripVertical } from 'lucide-react' import { ThemeEditorDialog } from '@/components/ThemeEditorDialog' import { ThemeImportDialog } from '@/components/ThemeImportDialog' +interface SortableRowProps { + theme: ThemePreview + onEdit: (theme: ThemePreview) => void + onDelete: (theme: ThemePreview) => void + onSetDefault: (themeId: string) => void +} + +function SortableRow({ theme, onEdit, onDelete, onSetDefault }: SortableRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: theme.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( + + +
+ +
+
+ Aa +
+
+
+
+ +
+ {theme.name} + {theme.isDefault && ( + + )} +
+
+ + + {theme.isPublic ? 'Public' : 'Private'} + + + + {theme.isDefault ? ( + + Default + + ) : ( + '-' + )} + + {theme.displayOrder ?? 0} + {theme.creator?.name || 'Unknown'} + + {new Date(theme.createdAt).toLocaleDateString()} + + +
+ {!theme.isDefault && ( + + )} + + +
+
+
+ ) +} + export default function ThemesPage() { const queryClient = useQueryClient() const [page, setPage] = useState(1) @@ -110,6 +244,55 @@ export default function ThemesPage() { }, }) + const setDefaultMutation = useMutation({ + mutationFn: (themeId: string) => themesApi.setDefaultTheme(themeId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['themes'] }) + toast.success('Default theme updated successfully') + }, + onError: (error: unknown) => { + const errorMessage = isThemesApiError(error) ? error.message : 'Failed to set default theme' + toast.error(errorMessage) + }, + }) + + const reorderMutation = useMutation({ + mutationFn: (themeIds: string[]) => themesApi.reorderThemes(themeIds), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['themes'] }) + toast.success('Themes reordered successfully') + }, + onError: (error: unknown) => { + const errorMessage = isThemesApiError(error) ? error.message : 'Failed to reorder themes' + toast.error(errorMessage) + }, + }) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (over && active.id !== over.id && data?.themes) { + const oldIndex = data.themes.findIndex((theme) => theme.id === active.id) + const newIndex = data.themes.findIndex((theme) => theme.id === over.id) + + const newThemes = arrayMove(data.themes, oldIndex, newIndex) + const themeIds = newThemes.map((theme) => theme.id) + + reorderMutation.mutate(themeIds) + } + } + + const handleSetDefault = (themeId: string) => { + setDefaultMutation.mutate(themeId) + } + const openCreateEditor = () => { setEditingTheme(null) setImportedData(null) @@ -253,87 +436,56 @@ export default function ThemesPage() { All Themes ({data?.total || 0}) - Manage theme colors and settings + + Manage theme colors and settings. Drag rows to reorder themes on this page. Set default theme with star icon. + {data && data.total > limit && ( + + Note: Reordering works per page. For full control, increase items per page or use search to filter. + + )} + {isLoading ? (
Loading themes...
) : ( <> - - - - Preview - Name - Public - Creator - Created - Actions - - - - {(data?.themes || []).map((theme) => ( - - -
-
- Aa -
-
-
- -
{theme.name}
-
- - - {theme.isPublic ? 'Public' : 'Private'} - - - {theme.creator?.name || 'Unknown'} - - {new Date(theme.createdAt).toLocaleDateString()} - - -
- - -
-
+ +
+ + + Preview + Name + Public + Default + Order + Creator + Created + Actions - ))} - -
+ + + t.id) || []} + strategy={verticalListSortingStrategy} + > + {(data?.themes || []).map((theme) => ( + + ))} + + + + {/* Pagination */} {data && data.totalPages > 1 && ( diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e6e4308..135b2cf 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -192,6 +192,8 @@ model Theme { icon String? description String? isPublic Boolean @default(false) + isDefault Boolean @default(false) + displayOrder Int @default(0) createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -201,4 +203,17 @@ model Theme { creator User @relation(fields: [createdBy], references: [id]) rooms Room[] + + @@index([isDefault]) + @@index([displayOrder]) +} + +model FeatureFlag { + id String @id @default(uuid()) + key String @unique + enabled Boolean @default(true) + description String? + updatedAt DateTime @default(now()) @updatedAt + + @@index([key]) } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 80a3d5f..8a67575 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -439,6 +439,36 @@ async function main() { console.log(`Theme "${theme.name}" created/updated`); } + // Create default feature flags + const defaultFeatureFlags = [ + { + key: 'themesEnabled', + enabled: true, + description: 'Enable/disable themes functionality globally', + }, + { + key: 'voiceModeEnabled', + enabled: true, + description: 'Enable/disable voice mode functionality globally', + }, + ]; + + for (const flag of defaultFeatureFlags) { + await prisma.featureFlag.upsert({ + where: { key: flag.key }, + update: { + enabled: flag.enabled, + description: flag.description, + }, + create: { + key: flag.key, + enabled: flag.enabled, + description: flag.description, + }, + }); + console.log(`Feature flag "${flag.key}" created/updated`); + } + console.log('Seed completed successfully!'); } diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index eb885a9..1c5bfc7 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -32,6 +32,10 @@ import { AdminGameHistoryService } from './game-history/admin-game-history.servi import { AdminThemesController } from './themes/admin-themes.controller'; import { AdminThemesService } from './themes/admin-themes.service'; +// Feature Flags +import { AdminFeatureFlagsController } from './feature-flags/admin-feature-flags.controller'; +import { AdminFeatureFlagsService } from './feature-flags/admin-feature-flags.service'; + // Guards import { AdminAuthGuard } from './guards/admin-auth.guard'; import { AdminGuard } from './guards/admin.guard'; @@ -61,6 +65,7 @@ import { AdminGuard } from './guards/admin.guard'; AdminAnalyticsController, AdminGameHistoryController, AdminThemesController, + AdminFeatureFlagsController, ], providers: [ AdminAuthService, @@ -70,6 +75,7 @@ import { AdminGuard } from './guards/admin.guard'; AdminAnalyticsService, AdminGameHistoryService, AdminThemesService, + AdminFeatureFlagsService, AdminAuthGuard, AdminGuard, ], diff --git a/backend/src/admin/feature-flags/admin-feature-flags.controller.ts b/backend/src/admin/feature-flags/admin-feature-flags.controller.ts new file mode 100644 index 0000000..f72d3af --- /dev/null +++ b/backend/src/admin/feature-flags/admin-feature-flags.controller.ts @@ -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); + } +} + diff --git a/backend/src/admin/feature-flags/admin-feature-flags.service.ts b/backend/src/admin/feature-flags/admin-feature-flags.service.ts new file mode 100644 index 0000000..1c043f3 --- /dev/null +++ b/backend/src/admin/feature-flags/admin-feature-flags.service.ts @@ -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; + } +} + diff --git a/backend/src/admin/feature-flags/dto/update-feature-flag.dto.ts b/backend/src/admin/feature-flags/dto/update-feature-flag.dto.ts new file mode 100644 index 0000000..9c20290 --- /dev/null +++ b/backend/src/admin/feature-flags/dto/update-feature-flag.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class UpdateFeatureFlagDto { + @IsBoolean() + enabled: boolean; + + @IsString() + @IsOptional() + description?: string; +} + diff --git a/backend/src/admin/themes/admin-themes.controller.ts b/backend/src/admin/themes/admin-themes.controller.ts index 93258b6..5cc7f73 100644 --- a/backend/src/admin/themes/admin-themes.controller.ts +++ b/backend/src/admin/themes/admin-themes.controller.ts @@ -14,6 +14,7 @@ import { AdminThemesService } from './admin-themes.service'; import { ThemeFiltersDto } from './dto/theme-filters.dto'; import { CreateThemeDto } from './dto/create-theme.dto'; import { UpdateThemeDto } from './dto/update-theme.dto'; +import { ReorderThemesDto } from './dto/reorder-themes.dto'; import { AdminAuthGuard } from '../guards/admin-auth.guard'; import { AdminGuard } from '../guards/admin.guard'; @@ -46,4 +47,14 @@ export class AdminThemesController { remove(@Param('id') id: string) { return this.adminThemesService.remove(id); } + + @Patch(':id/set-default') + setDefault(@Param('id') id: string) { + return this.adminThemesService.setDefaultTheme(id); + } + + @Patch('reorder') + reorder(@Body() reorderDto: ReorderThemesDto) { + return this.adminThemesService.reorderThemes(reorderDto.themeIds); + } } diff --git a/backend/src/admin/themes/admin-themes.service.ts b/backend/src/admin/themes/admin-themes.service.ts index a6519c0..7e33084 100644 --- a/backend/src/admin/themes/admin-themes.service.ts +++ b/backend/src/admin/themes/admin-themes.service.ts @@ -34,6 +34,8 @@ export class AdminThemesService { name: true, description: true, isPublic: true, + isDefault: true, + displayOrder: true, colors: true, settings: true, createdAt: true, @@ -46,7 +48,10 @@ export class AdminThemesService { }, }, }, - orderBy: { createdAt: 'desc' }, + orderBy: [ + { displayOrder: 'asc' }, + { name: 'asc' }, + ], }), this.prisma.theme.count({ where }), ]); @@ -82,12 +87,32 @@ export class AdminThemesService { } async create(createThemeDto: CreateThemeDto, createdBy: string) { + // Если устанавливается тема по умолчанию, снимаем флаг с остальных + if (createThemeDto.isDefault === true) { + await this.prisma.theme.updateMany({ + where: { isDefault: true }, + data: { isDefault: false }, + }); + } + + // Определяем displayOrder: если не указан, ставим максимальный + 1 + let displayOrder = createThemeDto.displayOrder; + if (displayOrder === undefined) { + const maxOrderTheme = await this.prisma.theme.findFirst({ + orderBy: { displayOrder: 'desc' }, + select: { displayOrder: true }, + }); + displayOrder = (maxOrderTheme?.displayOrder ?? -1) + 1; + } + return this.prisma.theme.create({ data: { name: createThemeDto.name, icon: createThemeDto.icon, description: createThemeDto.description, isPublic: createThemeDto.isPublic ?? true, + isDefault: createThemeDto.isDefault ?? false, + displayOrder, createdBy, colors: JSON.parse(JSON.stringify(createThemeDto.colors)) as Prisma.InputJsonValue, settings: JSON.parse(JSON.stringify(createThemeDto.settings)) as Prisma.InputJsonValue, @@ -113,6 +138,14 @@ export class AdminThemesService { throw new NotFoundException('Theme not found'); } + // Если устанавливается тема по умолчанию, снимаем флаг с остальных + if (updateThemeDto.isDefault === true) { + await this.prisma.theme.updateMany({ + where: { isDefault: true, id: { not: id } }, + data: { isDefault: false }, + }); + } + const updateData: Prisma.ThemeUpdateInput = { ...(updateThemeDto.name !== undefined && { name: updateThemeDto.name }), ...(updateThemeDto.icon !== undefined && { icon: updateThemeDto.icon }), @@ -120,6 +153,12 @@ export class AdminThemesService { ...(updateThemeDto.isPublic !== undefined && { isPublic: updateThemeDto.isPublic, }), + ...(updateThemeDto.isDefault !== undefined && { + isDefault: updateThemeDto.isDefault, + }), + ...(updateThemeDto.displayOrder !== undefined && { + displayOrder: updateThemeDto.displayOrder, + }), ...(updateThemeDto.colors && { colors: JSON.parse( JSON.stringify(updateThemeDto.colors), @@ -173,8 +212,81 @@ export class AdminThemesService { description: true, colors: true, settings: true, + isDefault: true, + displayOrder: true, }, - orderBy: { name: 'asc' }, + orderBy: [ + { displayOrder: 'asc' }, + { name: 'asc' }, + ], + }); + } + + async setDefaultTheme(id: string) { + const theme = await this.prisma.theme.findUnique({ + where: { id }, + }); + + if (!theme) { + throw new NotFoundException('Theme not found'); + } + + // Снимаем флаг isDefault со всех тем + await this.prisma.theme.updateMany({ + where: { isDefault: true }, + data: { isDefault: false }, + }); + + // Устанавливаем флаг для выбранной темы + return this.prisma.theme.update({ + where: { id }, + data: { isDefault: true }, + include: { + creator: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + async reorderThemes(themeIds: string[]) { + // Получаем текущие displayOrder для переупорядочиваемых тем + const currentThemes = await this.prisma.theme.findMany({ + where: { id: { in: themeIds } }, + select: { id: true, displayOrder: true }, + }); + + // Находим минимальный displayOrder среди переупорядочиваемых тем + // Это будет стартовая позиция для нового порядка + const minOrder = currentThemes.length > 0 + ? Math.min(...currentThemes.map((t) => t.displayOrder)) + : 0; + + // Обновляем displayOrder последовательно, начиная с minOrder + // Это сохранит относительный порядок относительно других тем + const updates = themeIds.map((themeId, index) => + this.prisma.theme.update({ + where: { id: themeId }, + data: { displayOrder: minOrder + index }, + }) + ); + + await Promise.all(updates); + + // Возвращаем обновленные темы + return this.prisma.theme.findMany({ + where: { id: { in: themeIds } }, + select: { + id: true, + name: true, + isDefault: true, + displayOrder: true, + }, + orderBy: { displayOrder: 'asc' }, }); } } diff --git a/backend/src/admin/themes/dto/create-theme.dto.ts b/backend/src/admin/themes/dto/create-theme.dto.ts index d7c8eea..70cbcc6 100644 --- a/backend/src/admin/themes/dto/create-theme.dto.ts +++ b/backend/src/admin/themes/dto/create-theme.dto.ts @@ -111,6 +111,48 @@ export class ThemeSettingsDto { @IsOptional() @Type(() => Number) particleInitialDelayMax?: number; + + // Finish Screen Settings + @IsString() + @IsOptional() + finishScreenTitle?: string; + + @IsString() + @IsOptional() + finishScreenSubtitle?: string; + + @IsString() + @IsOptional() + finishScreenBgColor?: string; + + @IsString() + @IsOptional() + finishScreenCardBg?: string; + + @IsBoolean() + @IsOptional() + finishScreenTop3Enabled?: boolean; + + @IsString() + @IsOptional() + finishScreenFirstPlaceColor?: string; + + @IsString() + @IsOptional() + finishScreenSecondPlaceColor?: string; + + @IsString() + @IsOptional() + finishScreenThirdPlaceColor?: string; + + @IsBoolean() + @IsOptional() + finishScreenAnimationEnabled?: boolean; + + @IsNumber() + @IsOptional() + @Type(() => Number) + finishScreenAnimationDelay?: number; } export class CreateThemeDto { @@ -129,6 +171,15 @@ export class CreateThemeDto { @IsOptional() isPublic?: boolean; + @IsBoolean() + @IsOptional() + isDefault?: boolean; + + @IsNumber() + @IsOptional() + @Type(() => Number) + displayOrder?: number; + @IsObject() @ValidateNested() @Type(() => ThemeColorsDto) diff --git a/backend/src/admin/themes/dto/reorder-themes.dto.ts b/backend/src/admin/themes/dto/reorder-themes.dto.ts new file mode 100644 index 0000000..057469d --- /dev/null +++ b/backend/src/admin/themes/dto/reorder-themes.dto.ts @@ -0,0 +1,9 @@ +import { IsArray, IsString, ArrayMinSize } from 'class-validator'; + +export class ReorderThemesDto { + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + themeIds: string[]; +} + diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index d07d00c..2a6708b 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -247,11 +247,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On currentRevealed.push(payload.answerId); revealed[payload.questionId] = currentRevealed; - // Начисляем очки - await this.prisma.participant.update({ - where: { id: payload.participantId }, - data: { score: { increment: answer.points } } - }); + // Начисляем очки текущему игроку (не тому, кто открыл ответ) + if (room.currentPlayerId) { + await this.prisma.participant.update({ + where: { id: room.currentPlayerId }, + data: { score: { increment: answer.points } } + }); + } // Сохраняем revealedAnswers await this.prisma.room.update({ @@ -259,9 +261,9 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On data: { revealedAnswers: revealed as Prisma.InputJsonValue } }); - // Определяем следующего игрока + // Определяем следующего игрока на основе текущего игрока const participants = room.participants; - const currentIdx = participants.findIndex((p) => p.id === payload.participantId); + const currentIdx = participants.findIndex((p) => p.id === room.currentPlayerId); const nextIdx = (currentIdx + 1) % participants.length; const nextPlayerId = participants[nextIdx]?.id; @@ -333,7 +335,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On } // КЛЮЧЕВОЙ МЕТОД: Отправка полного состояния - private async broadcastFullState(roomCode: string) { + public async broadcastFullState(roomCode: string) { const room = (await this.prisma.room.findUnique({ where: { code: roomCode }, include: { diff --git a/backend/src/game/game.module.ts b/backend/src/game/game.module.ts index 19ec1f6..65ff939 100644 --- a/backend/src/game/game.module.ts +++ b/backend/src/game/game.module.ts @@ -7,6 +7,6 @@ import { RoomPackModule } from '../room-pack/room-pack.module'; @Module({ imports: [forwardRef(() => RoomsModule), RoomPackModule], providers: [GameGateway, RoomEventsService], - exports: [RoomEventsService], + exports: [RoomEventsService, GameGateway], }) export class GameModule {} diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts index 24cd8a2..12c33ec 100644 --- a/backend/src/rooms/rooms.service.ts +++ b/backend/src/rooms/rooms.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException, import { PrismaService } from '../prisma/prisma.service'; import { customAlphabet } from 'nanoid'; import { RoomEventsService } from '../game/room-events.service'; +import { GameGateway } from '../game/game.gateway'; import { RoomPackService } from '../room-pack/room-pack.service'; const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6); @@ -12,6 +13,8 @@ export class RoomsService { private prisma: PrismaService, @Inject(forwardRef(() => RoomEventsService)) private roomEventsService: RoomEventsService, + @Inject(forwardRef(() => GameGateway)) + private gameGateway: GameGateway, private roomPackService: RoomPackService, ) {} @@ -139,12 +142,16 @@ export class RoomsService { include: { user: true }, }, questionPack: true, + roomPack: true, + theme: true, }, }); // Отправляем событие roomUpdate всем клиентам в комнате if (updatedRoom) { this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom); + // Также отправляем gameStateUpdated через broadcastFullState + await this.gameGateway.broadcastFullState(updatedRoom.code); } return participant; @@ -231,6 +238,8 @@ export class RoomsService { // Отправляем событие roomUpdate всем клиентам в комнате if (updatedRoom) { this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom); + // Также отправляем gameStateUpdated через broadcastFullState + await this.gameGateway.broadcastFullState(updatedRoom.code); } return participant; @@ -286,11 +295,14 @@ export class RoomsService { }, questionPack: true, roomPack: true, + theme: true, }, }); // Отправляем обновление через WebSocket this.roomEventsService.emitRoomUpdate(room.code, room); + // Также отправляем gameStateUpdated через broadcastFullState + await this.gameGateway.broadcastFullState(room.code); return room; } @@ -312,11 +324,15 @@ export class RoomsService { }, questionPack: true, roomPack: true, + theme: true, }, }); if (room) { this.roomEventsService.emitRoomPackUpdated(room.code, room); + // Также отправляем gameStateUpdated через broadcastFullState для синхронизации вопросов + // (если метод вызывается напрямую через REST API, а не через WebSocket) + await this.gameGateway.broadcastFullState(room.code); } return room; } @@ -354,10 +370,14 @@ export class RoomsService { include: { user: true }, }, questionPack: true, + roomPack: true, + theme: true, }, }); this.roomEventsService.emitRoomUpdate(room.code, room); + // Также отправляем gameStateUpdated через broadcastFullState + await this.gameGateway.broadcastFullState(room.code); return room; } @@ -428,11 +448,16 @@ export class RoomsService { include: { user: true }, }, questionPack: true, + roomPack: true, + theme: true, }, }); if (room) { this.roomEventsService.emitPlayerKicked(room.code, { participantId, room }); + // Также отправляем gameStateUpdated через broadcastFullState для синхронизации участников + // (если метод вызывается напрямую через REST API, а не через WebSocket) + await this.gameGateway.broadcastFullState(room.code); } return room; @@ -521,11 +546,15 @@ export class RoomsService { include: { user: true }, }, questionPack: true, + roomPack: true, + theme: true, }, }); if (room) { this.roomEventsService.emitRoomUpdate(room.code, room); + // Также отправляем gameStateUpdated через broadcastFullState + await this.gameGateway.broadcastFullState(room.code); } return room; diff --git a/backend/src/themes/themes.controller.ts b/backend/src/themes/themes.controller.ts index ae6ce76..7aacd6f 100644 --- a/backend/src/themes/themes.controller.ts +++ b/backend/src/themes/themes.controller.ts @@ -22,8 +22,13 @@ export class ThemesController { description: true, colors: true, settings: true, + isDefault: true, + displayOrder: true, }, - orderBy: { name: 'asc' }, + orderBy: [ + { displayOrder: 'asc' }, + { name: 'asc' }, + ], }); } diff --git a/src/components/Answer.css b/src/components/Answer.css index 52ba2a9..50a9cc8 100644 --- a/src/components/Answer.css +++ b/src/components/Answer.css @@ -4,7 +4,6 @@ border: 3px solid rgba(255, 255, 255, 0.2); border-radius: 20px; padding: clamp(8px, 1.5vh, 20px) clamp(12px, 1.5vw, 20px); - cursor: pointer; transition: all 0.3s ease; display: flex; flex-direction: column; @@ -20,6 +19,11 @@ scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.05); } +/* Cursor pointer only for clickable button state */ +.answer-button:not(.answer-revealed) { + cursor: pointer; +} + /* Горизонтальный layout для узких кнопок */ @media (max-width: 1000px) { .answer-button { @@ -30,21 +34,18 @@ } } -.answer-button:hover:not(:disabled) { +/* Hover and active effects only for clickable button state (hidden answer) */ +.answer-button:not(.answer-revealed):hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(255, 215, 0, 0.4); border-color: rgba(255, 215, 0, 0.6); background: rgba(255, 255, 255, 0.15); } -.answer-button:active:not(:disabled) { +.answer-button:not(.answer-revealed):active { transform: translateY(-2px); } -.answer-button:disabled { - cursor: default; -} - .answer-hidden { animation: pulse 2s ease-in-out infinite; } @@ -93,7 +94,8 @@ flex-shrink: 0; } -.answer-button:hover:not(:disabled) .answer-points-hidden { +/* Hover effect for hidden answer points */ +.answer-button:not(.answer-revealed):hover .answer-points-hidden { opacity: 1; filter: blur(0); transform: scale(1.1); diff --git a/src/components/Answer.jsx b/src/components/Answer.jsx index 6cb18f4..a7a936d 100644 --- a/src/components/Answer.jsx +++ b/src/components/Answer.jsx @@ -32,52 +32,61 @@ const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => { } }, [isRevealed, autoPlayAnswers, roomId, questionId, answer.id, speak]) - return ( - + + ) + } + + return ( +
+
+ {answer.text} +
+ + {answer.points} + + {roomId && questionId && answer.id && ( + + )} +
+
+
) } diff --git a/src/components/Game.css b/src/components/Game.css index 2b1d026..f26e611 100644 --- a/src/components/Game.css +++ b/src/components/Game.css @@ -14,7 +14,8 @@ margin-top: clamp(5px, 1vh, 10px); margin-bottom: clamp(5px, 1vh, 15px); display: flex; - flex-direction: column; + flex-direction: row; + flex-wrap: wrap; gap: clamp(5px, 1vh, 10px); align-items: center; flex-shrink: 0; diff --git a/src/components/GameFinishedScreen.css b/src/components/GameFinishedScreen.css new file mode 100644 index 0000000..74441e7 --- /dev/null +++ b/src/components/GameFinishedScreen.css @@ -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; + } +} diff --git a/src/components/GameFinishedScreen.jsx b/src/components/GameFinishedScreen.jsx new file mode 100644 index 0000000..6d8c760 --- /dev/null +++ b/src/components/GameFinishedScreen.jsx @@ -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 ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +

+ Нет участников +

+
+
+ ) + } + + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} + +
+ {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 ( +
+
+ {getPlaceLabel(index)} +
+
{participant.name}
+
+ {score} очков +
+
+ ) + })} +
+
+
+ ) +} + +export default GameFinishedScreen diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx index 808dfd8..1aca3e3 100644 --- a/src/components/GameManagementModal.jsx +++ b/src/components/GameManagementModal.jsx @@ -18,6 +18,7 @@ const GameManagementModal = ({ availablePacks = [], onStartGame, onEndGame, + onRestartGame, onNextQuestion, onPreviousQuestion, onRevealAnswer, @@ -904,6 +905,15 @@ const GameManagementModal = ({ )} + {gameStatus === 'FINISHED' && onRestartGame && ( + + )} + {/* Управление ответами - показывается только во время активной игры */} {gameStatus === 'PLAYING' && currentQuestion && (
diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx index c86b985..6e7e6dc 100644 --- a/src/context/ThemeContext.jsx +++ b/src/context/ThemeContext.jsx @@ -35,7 +35,8 @@ export const ThemeProvider = ({ children }) => { // Set default theme if no theme is selected or current theme is not found setCurrentTheme((prevTheme) => { if (!prevTheme || !data.find((t) => t.id === prevTheme)) { - const defaultTheme = data.find((t) => t.id === 'new-year') || data[0]; + // Find theme marked as default, or use first theme as fallback + const defaultTheme = data.find((t) => t.isDefault === true) || data[0]; if (defaultTheme) { localStorage.setItem('app-theme', defaultTheme.id); return defaultTheme.id; diff --git a/src/hooks/useRoom.js b/src/hooks/useRoom.js index ecb49b0..f8e1a59 100644 --- a/src/hooks/useRoom.js +++ b/src/hooks/useRoom.js @@ -25,11 +25,16 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { setParticipants(response.data.participants || []); setError(null); setRequiresPassword(false); + + // ✅ Подключаться к WebSocket только после успешной загрузки комнаты + socketService.connect(); + socketService.joinRoom(roomCode, user?.id); } catch (err) { // Проверяем, требуется ли пароль (401 Unauthorized) if (err.response?.status === 401) { setRequiresPassword(true); setError('Room password required'); + // ❌ НЕ подключаться к WebSocket, если требуется пароль } else { setError(err.response?.data?.message || err.message); setRequiresPassword(false); @@ -42,13 +47,7 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { fetchRoom(); - // Connect to WebSocket - socketService.connect(); - - // Join the room via WebSocket - socketService.joinRoom(roomCode, user?.id); - - // Listen for room updates + // Listen for room updates (регистрируются всегда, но не подключаются если requiresPassword) const handleRoomUpdate = (updatedRoom) => { setRoom(updatedRoom); setParticipants(updatedRoom.participants || []); @@ -159,6 +158,11 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { setParticipants(response.data.participants || []); setError(null); setRequiresPassword(false); + + // ✅ После успешной загрузки комнаты с паролем подключаемся к WebSocket + socketService.connect(); + socketService.joinRoom(roomCode, user?.id); + return response.data; } catch (err) { if (err.response?.status === 401) { diff --git a/src/pages/GamePage.jsx b/src/pages/GamePage.jsx index 4ac78b2..3e74d62 100644 --- a/src/pages/GamePage.jsx +++ b/src/pages/GamePage.jsx @@ -6,6 +6,7 @@ import { questionsApi, roomsApi } from '../services/api'; import QRCode from 'qrcode'; import socketService from '../services/socket'; import Game from '../components/Game'; +import GameFinishedScreen from '../components/GameFinishedScreen'; import QRModal from '../components/QRModal'; import GameManagementModal from '../components/GameManagementModal'; import ThemeSwitcher from '../components/ThemeSwitcher'; @@ -501,45 +502,54 @@ const GamePage = () => { )}
- {gameState.questions.length === 0 && ( -
-

- Вопросы не загружены. - {isHost - ? ' Откройте управление вопросами, чтобы добавить вопросы.' - : ' Ожидайте, пока ведущий добавит вопросы.'} -

-
- )} + {gameState.status === 'FINISHED' ? ( + + ) : ( + <> + {gameState.questions.length === 0 && ( +
+

+ Вопросы не загружены. + {isHost + ? ' Откройте управление вопросами, чтобы добавить вопросы.' + : ' Ожидайте, пока ведущий добавит вопросы.'} +

+
+ )} - {isSpectator && ( -
-

- 👀 Вы в роли зрителя. Вы можете наблюдать за игрой, но не можете отвечать на вопросы. -

-
- )} + {isSpectator && ( +
+

+ 👀 Вы в роли зрителя. Вы можете наблюдать за игрой, но не можете отвечать на вопросы. +

+
+ )} - + + + )}
{/* Modals */} diff --git a/src/pages/RoomPage.jsx b/src/pages/RoomPage.jsx index 722f190..64900fc 100644 --- a/src/pages/RoomPage.jsx +++ b/src/pages/RoomPage.jsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; +import { useTheme } from '../context/ThemeContext'; import { useRoom } from '../hooks/useRoom'; import { questionsApi } from '../services/api'; import QRCode from 'qrcode'; @@ -15,6 +16,10 @@ const RoomPage = () => { const { roomCode } = useParams(); const navigate = useNavigate(); const { user, loginAnonymous, loading: authLoading } = useAuth(); + const { changeTheme } = useTheme(); + + // Храним предыдущий themeId комнаты для отслеживания изменений + const previousThemeIdRef = useRef(null); // Callback для автоматической навигации при старте игры const handleGameStartedEvent = useCallback(() => { @@ -69,20 +74,16 @@ const RoomPage = () => { }, [roomCode]); // Проверка пароля: показываем модальное окно, если требуется пароль - // Хост не должен видеть модальное окно пароля (проверяется на бэкенде) + // Показываем независимо от авторизации - пароль проверяется первым useEffect(() => { - if (requiresPassword && !isPasswordModalOpen && !loading && user) { - // Проверяем, не является ли пользователь хостом - // Если это хост, то requiresPassword не должно быть true (бэкенд должен разрешить доступ) - setIsPasswordModalOpen(true); - } else if (requiresPassword && !isPasswordModalOpen && !loading && !user) { - // Если пользователь не авторизован, все равно показываем модальное окно - // После авторизации проверим, является ли он хостом + if (requiresPassword && !isPasswordModalOpen && !loading) { + // Показывать модальное окно пароля независимо от авторизации setIsPasswordModalOpen(true); } - }, [requiresPassword, isPasswordModalOpen, loading, user]); + }, [requiresPassword, isPasswordModalOpen, loading]); // Проверка авторизации и показ модального окна для ввода имени + // Показывать только если НЕТ пароля - пароль приоритетнее useEffect(() => { if (!authLoading && !user && room && !loading && !requiresPassword) { setIsNameModalOpen(true); @@ -211,6 +212,37 @@ const RoomPage = () => { } }, [room, roomCode, navigate]); + // Применяем тему из gameStateUpdated/roomUpdate + useEffect(() => { + if (!roomCode) return; + + const handleGameStateUpdated = (state) => { + const currentThemeId = state.themeId || null; + if (currentThemeId !== previousThemeIdRef.current) { + previousThemeIdRef.current = currentThemeId; + if (currentThemeId) { + changeTheme(currentThemeId); + } + } + }; + + // Также проверяем тему из room при изменении + if (room?.themeId) { + const currentThemeId = room.themeId || null; + if (currentThemeId !== previousThemeIdRef.current) { + previousThemeIdRef.current = currentThemeId; + if (currentThemeId) { + changeTheme(currentThemeId); + } + } + } + + socketService.on('gameStateUpdated', handleGameStateUpdated); + return () => { + socketService.off('gameStateUpdated', handleGameStateUpdated); + }; + }, [roomCode, room, changeTheme]); + const handleStartGame = () => { startGame(); navigate(`/game/${roomCode}`);