From 96577926c82e7d7fb6c21b3196ea450760f682e5 Mon Sep 17 00:00:00 2001
From: Dmitry
Date: Sat, 10 Jan 2026 00:36:49 +0300
Subject: [PATCH] admin
---
admin/src/App.tsx | 4 +
admin/src/api/rooms.ts | 192 +++++++
admin/src/api/themes.ts | 238 +++++++++
.../src/components/CreateAdminRoomDialog.tsx | 483 ++++++++++++++++++
admin/src/components/PackImportDialog.tsx | 304 +++++++++++
admin/src/components/ThemeEditorDialog.tsx | 413 +++++++++++++++
admin/src/components/layout/Layout.tsx | 6 +-
admin/src/pages/PacksPage.tsx | 79 ++-
admin/src/pages/RoomsPage.tsx | 354 +++++++++++++
admin/src/pages/ThemesPage.tsx | 381 ++++++++++++++
backend/prisma/schema.prisma | 12 +-
backend/src/admin/admin.module.ts | 8 +
.../src/admin/packs/admin-packs.controller.ts | 55 +-
.../src/admin/rooms/admin-rooms.controller.ts | 8 +
.../src/admin/rooms/admin-rooms.service.ts | 100 +++-
.../admin/rooms/dto/create-admin-room.dto.ts | 94 ++++
.../admin/themes/admin-themes.controller.ts | 49 ++
.../src/admin/themes/admin-themes.service.ts | 151 ++++++
.../src/admin/themes/dto/create-theme.dto.ts | 91 ++++
.../src/admin/themes/dto/theme-filters.dto.ts | 25 +
.../src/admin/themes/dto/update-theme.dto.ts | 4 +
backend/src/app.module.ts | 2 +
backend/src/game/game.gateway.ts | 51 ++
backend/src/rooms/rooms.controller.ts | 4 +-
backend/src/rooms/rooms.service.ts | 59 ++-
backend/src/themes/themes.controller.ts | 51 ++
backend/src/themes/themes.module.ts | 9 +
src/App.css | 6 +-
src/components/Answer.css | 2 +-
src/components/Game.css | 16 +-
src/components/GameManagementModal.css | 24 +-
src/components/GameManagementModal.jsx | 73 ++-
src/components/HostAdminPanel.css | 12 +-
src/components/NameInputModal.css | 10 +-
src/components/Players.css | 8 +-
src/components/PlayersModal.css | 10 +-
src/components/QRModal.css | 4 +-
src/components/Question.css | 6 +-
src/components/QuestionsModal.css | 56 +-
src/components/Snowflakes.jsx | 67 ++-
src/components/VoiceSettings.css | 4 +-
src/context/ThemeContext.jsx | 105 +++-
src/hooks/useRoom.js | 14 +-
src/index.css | 166 +++++-
src/pages/CreateRoom.jsx | 14 +
src/pages/GamePage.css | 2 +-
src/services/api.js | 4 +-
47 files changed, 3640 insertions(+), 190 deletions(-)
create mode 100644 admin/src/api/rooms.ts
create mode 100644 admin/src/api/themes.ts
create mode 100644 admin/src/components/CreateAdminRoomDialog.tsx
create mode 100644 admin/src/components/PackImportDialog.tsx
create mode 100644 admin/src/components/ThemeEditorDialog.tsx
create mode 100644 admin/src/pages/RoomsPage.tsx
create mode 100644 admin/src/pages/ThemesPage.tsx
create mode 100644 backend/src/admin/rooms/dto/create-admin-room.dto.ts
create mode 100644 backend/src/admin/themes/admin-themes.controller.ts
create mode 100644 backend/src/admin/themes/admin-themes.service.ts
create mode 100644 backend/src/admin/themes/dto/create-theme.dto.ts
create mode 100644 backend/src/admin/themes/dto/theme-filters.dto.ts
create mode 100644 backend/src/admin/themes/dto/update-theme.dto.ts
create mode 100644 backend/src/themes/themes.controller.ts
create mode 100644 backend/src/themes/themes.module.ts
diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index 290e9bb..00bc34e 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -4,6 +4,8 @@ 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 Layout from '@/components/layout/Layout'
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
@@ -27,6 +29,8 @@ function App() {
} />
} />
} />
+ } />
+ } />
) : (
diff --git a/admin/src/api/rooms.ts b/admin/src/api/rooms.ts
new file mode 100644
index 0000000..fe14752
--- /dev/null
+++ b/admin/src/api/rooms.ts
@@ -0,0 +1,192 @@
+import { adminApiClient } from './client'
+import type { AxiosError } from 'axios'
+
+export interface RoomDto {
+ id: string
+ code: string
+ status: 'WAITING' | 'PLAYING' | 'FINISHED'
+ hostId: string
+ createdAt: string
+ expiresAt?: string
+ isAdminRoom: boolean
+ customCode?: string
+ activeFrom?: string
+ activeTo?: string
+ themeId?: string
+ questionPackId?: string
+ uiControls?: {
+ allowThemeChange?: boolean
+ allowPackChange?: boolean
+ allowNameChange?: boolean
+ allowScoreEdit?: boolean
+ }
+ maxPlayers: number
+ allowSpectators: boolean
+ timerEnabled: boolean
+ timerDuration: number
+ host: {
+ id: string
+ name?: string
+ email?: string
+ }
+ theme?: {
+ id: string
+ name: string
+ isPublic: boolean
+ }
+ questionPack?: {
+ id: string
+ name: string
+ }
+ _count?: {
+ participants: number
+ }
+ startedAt?: string
+ finishedAt?: string
+}
+
+export interface CreateAdminRoomDto {
+ hostId: string
+ hostName?: string
+ customCode?: string
+ activeFrom?: string
+ activeTo?: string
+ themeId?: string
+ questionPackId?: string
+ uiControls?: {
+ allowThemeChange?: boolean
+ allowPackChange?: boolean
+ allowNameChange?: boolean
+ allowScoreEdit?: boolean
+ }
+ settings?: {
+ maxPlayers?: number
+ allowSpectators?: boolean
+ timerEnabled?: boolean
+ timerDuration?: number
+ }
+}
+
+export interface RoomsResponse {
+ rooms: RoomDto[]
+ total: number
+ page: number
+ limit: number
+ totalPages: number
+}
+
+export interface RoomsApiError {
+ message: string
+ statusCode?: number
+ originalError?: unknown
+ name: 'RoomsApiError'
+}
+
+export function createRoomsApiError(
+ message: string,
+ statusCode?: number,
+ originalError?: unknown
+): RoomsApiError {
+ return {
+ message,
+ statusCode,
+ originalError,
+ name: 'RoomsApiError',
+ }
+}
+
+export function isRoomsApiError(error: unknown): error is RoomsApiError {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'name' in error &&
+ error.name === 'RoomsApiError'
+ )
+}
+
+export const roomsApi = {
+ getRooms: async (params?: {
+ page?: number
+ limit?: number
+ status?: 'WAITING' | 'PLAYING' | 'FINISHED'
+ dateFrom?: string
+ dateTo?: string
+ }): Promise => {
+ try {
+ const response = await adminApiClient.get('/api/admin/rooms', {
+ params: {
+ page: params?.page || 1,
+ limit: params?.limit || 20,
+ status: params?.status,
+ dateFrom: params?.dateFrom,
+ dateTo: params?.dateTo,
+ },
+ })
+ return response.data
+ } catch (error) {
+ const axiosError = error as AxiosError<{ message?: string }>
+ throw createRoomsApiError(
+ axiosError.response?.data?.message || 'Failed to load rooms',
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+
+ getRoom: async (roomId: string): Promise => {
+ try {
+ const response = await adminApiClient.get(`/api/admin/rooms/${roomId}`)
+ return response.data
+ } catch (error) {
+ const axiosError = error as AxiosError<{ message?: string }>
+ if (axiosError.response?.status === 404) {
+ throw createRoomsApiError(
+ `Room not found: The room with ID "${roomId}" does not exist.`,
+ axiosError.response.status,
+ error
+ )
+ }
+ throw createRoomsApiError(
+ axiosError.response?.data?.message || `Failed to load room ${roomId}`,
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+
+ createAdminRoom: async (room: CreateAdminRoomDto): Promise => {
+ try {
+ const response = await adminApiClient.post('/api/admin/rooms', room)
+ return response.data
+ } catch (error) {
+ const axiosError = error as AxiosError<{ message?: string }>
+ throw createRoomsApiError(
+ axiosError.response?.data?.message || 'Failed to create room',
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+
+ deleteRoom: async (roomId: string): Promise<{ message: string }> => {
+ try {
+ const response = await adminApiClient.delete(`/api/admin/rooms/${roomId}`)
+ return response.data
+ } catch (error) {
+ const axiosError = error as AxiosError<{ message?: string }>
+ if (axiosError.response?.status === 404) {
+ throw createRoomsApiError(
+ `Room not found: The room with ID "${roomId}" does not exist.`,
+ axiosError.response.status,
+ error
+ )
+ }
+ throw createRoomsApiError(
+ axiosError.response?.data?.message || `Failed to delete room ${roomId}`,
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+}
+
diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts
new file mode 100644
index 0000000..aab4eb6
--- /dev/null
+++ b/admin/src/api/themes.ts
@@ -0,0 +1,238 @@
+import { adminApiClient } from './client'
+import type { AxiosError } from 'axios'
+
+export interface ThemeColors {
+ bgPrimary: string
+ bgOverlay: string
+ bgCard: string
+ bgCardHover: string
+ textPrimary: string
+ textSecondary: string
+ textGlow: string
+ accentPrimary: string
+ accentSecondary: string
+ accentSuccess: string
+ borderColor: string
+ borderGlow: string
+}
+
+export interface ThemeSettings {
+ shadowSm: string
+ shadowMd: string
+ shadowLg: string
+ blurAmount: string
+ borderRadiusSm: string
+ borderRadiusMd: string
+ borderRadiusLg: string
+ animationSpeed: string
+}
+
+export interface Theme {
+ id: string
+ name: string
+ isPublic: boolean
+ colors: ThemeColors
+ settings: ThemeSettings
+ createdAt: string
+ updatedAt: string
+ creator?: {
+ id: string
+ name: string | null
+ email: string | null
+ }
+}
+
+export interface ThemePreview {
+ id: string
+ name: string
+ isPublic: boolean
+ colors: ThemeColors
+ settings: ThemeSettings
+ createdAt: string
+ creator?: {
+ id: string
+ name: string | null
+ }
+}
+
+export interface CreateThemeDto {
+ name: string
+ isPublic?: boolean
+ colors: ThemeColors
+ settings: ThemeSettings
+}
+
+export interface UpdateThemeDto {
+ name?: string
+ isPublic?: boolean
+ colors?: ThemeColors
+ settings?: ThemeSettings
+}
+
+export interface PaginatedThemesResponse {
+ themes: ThemePreview[]
+ total: number
+ page: number
+ limit: number
+ totalPages: number
+}
+
+export interface ThemesApiError {
+ message: string
+ statusCode?: number
+ originalError?: unknown
+ name: 'ThemesApiError'
+}
+
+export function createThemesApiError(
+ message: string,
+ statusCode?: number,
+ originalError?: unknown
+): ThemesApiError {
+ return {
+ message,
+ statusCode,
+ originalError,
+ name: 'ThemesApiError',
+ }
+}
+
+export function isThemesApiError(error: unknown): error is ThemesApiError {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'name' in error &&
+ error.name === 'ThemesApiError'
+ )
+}
+
+export const themesApi = {
+ getThemes: async (params?: {
+ page?: number
+ limit?: number
+ search?: string
+ isPublic?: boolean
+ }): Promise => {
+ try {
+ const response = await adminApiClient.get('/api/admin/themes', {
+ params: {
+ page: params?.page || 1,
+ limit: params?.limit || 20,
+ search: params?.search,
+ isPublic: params?.isPublic,
+ },
+ })
+ return response.data
+ } catch (error) {
+ const axiosError = error as AxiosError<{ message?: string }>
+ throw createThemesApiError(
+ axiosError.response?.data?.message || 'Failed to load themes',
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+
+ getTheme: async (themeId: string): Promise => {
+ try {
+ const response = await adminApiClient.get(`/api/admin/themes/${themeId}`)
+ 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 load theme ${themeId}`,
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+
+ createTheme: async (theme: CreateThemeDto): Promise => {
+ try {
+ const response = await adminApiClient.post('/api/admin/themes', theme)
+ return response.data
+ } catch (error) {
+ const axiosError = error as AxiosError<{ message?: string }>
+ throw createThemesApiError(
+ axiosError.response?.data?.message || 'Failed to create theme',
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+
+ updateTheme: async (themeId: string, theme: UpdateThemeDto): Promise => {
+ try {
+ const response = await adminApiClient.patch(`/api/admin/themes/${themeId}`, theme)
+ 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 update theme ${themeId}`,
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+
+ deleteTheme: async (themeId: string): Promise<{ message: string }> => {
+ try {
+ const response = await adminApiClient.delete(`/api/admin/themes/${themeId}`)
+ 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 delete theme ${themeId}`,
+ axiosError.response?.status,
+ error
+ )
+ }
+ },
+}
+
+export const DEFAULT_THEME_COLORS: ThemeColors = {
+ bgPrimary: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ bgOverlay: 'rgba(0, 0, 0, 0.7)',
+ bgCard: 'rgba(255, 255, 255, 0.1)',
+ bgCardHover: 'rgba(255, 255, 255, 0.2)',
+ textPrimary: '#ffffff',
+ textSecondary: 'rgba(255, 255, 255, 0.8)',
+ textGlow: '#ffd700',
+ accentPrimary: '#ffd700',
+ accentSecondary: '#ffed4e',
+ accentSuccess: '#4ade80',
+ borderColor: 'rgba(255, 255, 255, 0.2)',
+ borderGlow: 'rgba(255, 215, 0, 0.3)',
+}
+
+export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
+ shadowSm: '0 1px 2px rgba(0, 0, 0, 0.1)',
+ shadowMd: '0 4px 6px rgba(0, 0, 0, 0.1)',
+ shadowLg: '0 10px 15px rgba(0, 0, 0, 0.2)',
+ blurAmount: '10px',
+ borderRadiusSm: '4px',
+ borderRadiusMd: '8px',
+ borderRadiusLg: '12px',
+ animationSpeed: '0.3s',
+}
diff --git a/admin/src/components/CreateAdminRoomDialog.tsx b/admin/src/components/CreateAdminRoomDialog.tsx
new file mode 100644
index 0000000..38bfcc4
--- /dev/null
+++ b/admin/src/components/CreateAdminRoomDialog.tsx
@@ -0,0 +1,483 @@
+import { useState } from 'react'
+import { useQuery, useMutation } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { roomsApi, type CreateAdminRoomDto } from '@/api/rooms'
+import { usersApi } from '@/api/users'
+import { themesApi } from '@/api/themes'
+import { packsApi } from '@/api/packs'
+import type { AxiosError } from 'axios'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+
+interface CreateAdminRoomDialogProps {
+ open: boolean
+ onClose: () => void
+ onSuccess: () => void
+}
+
+export function CreateAdminRoomDialog({
+ open,
+ onClose,
+ onSuccess,
+}: CreateAdminRoomDialogProps) {
+ const [formData, setFormData] = useState({
+ hostId: '',
+ hostName: 'Ведущий',
+ customCode: '',
+ activeFrom: undefined,
+ activeTo: undefined,
+ themeId: undefined,
+ questionPackId: undefined,
+ uiControls: {
+ allowThemeChange: true,
+ allowPackChange: true,
+ allowNameChange: true,
+ allowScoreEdit: true,
+ },
+ settings: {
+ maxPlayers: 10,
+ allowSpectators: true,
+ timerEnabled: false,
+ timerDuration: 30,
+ },
+ })
+
+ const [showAdvanced, setShowAdvanced] = useState(false)
+
+ // Fetch data
+ const { data: usersData } = useQuery({
+ queryKey: ['users', 1],
+ queryFn: () => usersApi.getUsers({ page: 1, limit: 100 }),
+ })
+
+ const { data: themesData } = useQuery({
+ queryKey: ['themes'],
+ queryFn: () => themesApi.getThemes({ page: 1, limit: 100 }),
+ })
+
+ const { data: packsData } = useQuery({
+ queryKey: ['packs', 1],
+ queryFn: () => packsApi.getPacks({ page: 1, limit: 100 }),
+ })
+
+ // Create mutation
+ const createMutation = useMutation({
+ mutationFn: (data: CreateAdminRoomDto) => roomsApi.createAdminRoom(data),
+ onSuccess: () => {
+ toast.success('Room created successfully')
+ onSuccess()
+ // Reset form
+ setFormData({
+ hostId: '',
+ hostName: 'Ведущий',
+ customCode: '',
+ activeFrom: undefined,
+ activeTo: undefined,
+ themeId: undefined,
+ questionPackId: undefined,
+ uiControls: {
+ allowThemeChange: true,
+ allowPackChange: true,
+ allowNameChange: true,
+ allowScoreEdit: true,
+ },
+ settings: {
+ maxPlayers: 10,
+ allowSpectators: true,
+ timerEnabled: false,
+ timerDuration: 30,
+ },
+ })
+ },
+ onError: (error: unknown) => {
+ const axiosError = error as AxiosError<{ message?: string }>
+ toast.error(axiosError.response?.data?.message || 'Failed to create room')
+ },
+ })
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!formData.hostId) {
+ toast.error('Please select a host')
+ return
+ }
+
+ // Prepare data - remove undefined values
+ const submitData: CreateAdminRoomDto = {
+ hostId: formData.hostId,
+ hostName: formData.hostName || undefined,
+ customCode: formData.customCode || undefined,
+ activeFrom: formData.activeFrom || undefined,
+ activeTo: formData.activeTo || undefined,
+ themeId: formData.themeId || undefined,
+ questionPackId: formData.questionPackId || undefined,
+ uiControls: formData.uiControls,
+ settings: formData.settings,
+ }
+
+ createMutation.mutate(submitData)
+ }
+
+ const formatDateTimeLocal = (dateStr?: string) => {
+ if (!dateStr) return ''
+ const date = new Date(dateStr)
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ const hours = String(date.getHours()).padStart(2, '0')
+ const minutes = String(date.getMinutes()).padStart(2, '0')
+ return `${year}-${month}-${day}T${hours}:${minutes}`
+ }
+
+ const parseDateTimeLocal = (value: string) => {
+ if (!value) return undefined
+ return new Date(value).toISOString()
+ }
+
+ return (
+
+ )
+}
+
diff --git a/admin/src/components/PackImportDialog.tsx b/admin/src/components/PackImportDialog.tsx
new file mode 100644
index 0000000..2a042c9
--- /dev/null
+++ b/admin/src/components/PackImportDialog.tsx
@@ -0,0 +1,304 @@
+import { useState, useRef } from 'react'
+import { toast } from 'sonner'
+import { packsApi, isPacksApiError } from '@/api/packs'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Upload, FileJson } from 'lucide-react'
+
+interface PackImportDialogProps {
+ open: boolean
+ onImport: () => void
+ onClose: () => void
+}
+
+export function PackImportDialog({ open, onImport, onClose }: PackImportDialogProps) {
+ const fileInputRef = useRef(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [jsonContent, setJsonContent] = useState('')
+ const [packInfo, setPackInfo] = useState({
+ name: '',
+ description: '',
+ category: '',
+ isPublic: true,
+ })
+ const [parseError, setParseError] = useState(null)
+ const [questionsCount, setQuestionsCount] = useState(null)
+
+ const resetForm = () => {
+ setJsonContent('')
+ setPackInfo({ name: '', description: '', category: '', isPublic: true })
+ setParseError(null)
+ setQuestionsCount(null)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+ }
+
+ const handleClose = () => {
+ resetForm()
+ onClose()
+ }
+
+ const parseJsonContent = (content: string) => {
+ try {
+ const parsed = JSON.parse(content)
+
+ // Check if it's an exported pack (has packInfo) or just questions array
+ let questions: any[]
+ if (parsed.packInfo && Array.isArray(parsed.questions)) {
+ // Exported pack format
+ questions = parsed.questions
+ setPackInfo({
+ name: parsed.packInfo.name || '',
+ description: parsed.packInfo.description || '',
+ category: parsed.packInfo.category || '',
+ isPublic: parsed.packInfo.isPublic ?? true,
+ })
+ } else if (Array.isArray(parsed.questions)) {
+ // Template format with questions array
+ questions = parsed.questions
+ } else if (Array.isArray(parsed)) {
+ // Just an array of questions
+ questions = parsed
+ } else {
+ throw new Error('Invalid format: expected questions array or exported pack format')
+ }
+
+ // Validate questions structure
+ const isValid = questions.every(
+ (q: any) =>
+ (q.question || q.text) &&
+ Array.isArray(q.answers) &&
+ q.answers.length > 0 &&
+ q.answers.every(
+ (a: any) => a.text && typeof a.points === 'number'
+ )
+ )
+
+ if (!isValid) {
+ throw new Error('Invalid question format. Each question must have "question" (or "text") and "answers" array with "text" and "points" fields.')
+ }
+
+ setParseError(null)
+ setQuestionsCount(questions.length)
+ return questions
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : 'Invalid JSON format'
+ setParseError(errorMessage)
+ setQuestionsCount(null)
+ return null
+ }
+ }
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ const content = event.target?.result as string
+ setJsonContent(content)
+ parseJsonContent(content)
+ }
+ reader.onerror = () => {
+ toast.error('Failed to read file')
+ }
+ reader.readAsText(file)
+ }
+
+ const handleJsonChange = (value: string) => {
+ setJsonContent(value)
+ if (value.trim()) {
+ parseJsonContent(value)
+ } else {
+ setParseError(null)
+ setQuestionsCount(null)
+ }
+ }
+
+ const handleImport = async () => {
+ if (!packInfo.name.trim()) {
+ toast.error('Pack name is required')
+ return
+ }
+ if (!packInfo.description.trim()) {
+ toast.error('Pack description is required')
+ return
+ }
+ if (!packInfo.category.trim()) {
+ toast.error('Pack category is required')
+ return
+ }
+ if (!jsonContent.trim()) {
+ toast.error('JSON content is required')
+ return
+ }
+
+ const parsed = parseJsonContent(jsonContent)
+ if (!parsed) {
+ toast.error('Invalid JSON format')
+ return
+ }
+
+ // Normalize questions to use 'question' field
+ const normalizedQuestions = parsed.map((q: any) => ({
+ question: q.question || q.text,
+ answers: q.answers.map((a: any) => ({
+ text: a.text,
+ points: a.points,
+ })),
+ }))
+
+ setIsLoading(true)
+ try {
+ await packsApi.importPack({
+ name: packInfo.name.trim(),
+ description: packInfo.description.trim(),
+ category: packInfo.category.trim(),
+ isPublic: packInfo.isPublic,
+ questions: normalizedQuestions,
+ })
+ toast.success(`Pack imported successfully with ${normalizedQuestions.length} questions`)
+ handleClose()
+ onImport()
+ } catch (error) {
+ const errorMessage = isPacksApiError(error) ? error.message : 'Failed to import pack'
+ toast.error(errorMessage)
+ console.error('Import error:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/admin/src/components/ThemeEditorDialog.tsx b/admin/src/components/ThemeEditorDialog.tsx
new file mode 100644
index 0000000..570c673
--- /dev/null
+++ b/admin/src/components/ThemeEditorDialog.tsx
@@ -0,0 +1,413 @@
+import { useState, useEffect } from 'react'
+import {
+ DEFAULT_THEME_COLORS,
+ DEFAULT_THEME_SETTINGS,
+ type ThemeColors,
+ type ThemeSettings,
+ type ThemePreview,
+} from '@/api/themes'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Eye, EyeOff } from 'lucide-react'
+
+interface ThemeEditorDialogProps {
+ open: boolean
+ theme: ThemePreview | null
+ onSave: (data: {
+ name: string
+ isPublic: boolean
+ colors: ThemeColors
+ settings: ThemeSettings
+ }) => void
+ onClose: () => void
+ isSaving?: boolean
+}
+
+interface ColorFieldProps {
+ label: string
+ value: string
+ onChange: (value: string) => void
+ description?: string
+}
+
+function ColorField({ label, value, onChange, description }: ColorFieldProps) {
+ const isGradient = value.includes('gradient') || value.includes('rgba')
+
+ return (
+
+
+
+ {!isGradient && (
+ onChange(e.target.value)}
+ className="w-10 h-10 rounded border cursor-pointer"
+ />
+ )}
+ onChange(e.target.value)}
+ placeholder={label}
+ className="flex-1"
+ />
+
+ {description && (
+
{description}
+ )}
+
+ )
+}
+
+export function ThemeEditorDialog({
+ open,
+ theme,
+ onSave,
+ onClose,
+ isSaving = false,
+}: ThemeEditorDialogProps) {
+ const [name, setName] = useState('')
+ const [isPublic, setIsPublic] = useState(false)
+ const [colors, setColors] = useState(DEFAULT_THEME_COLORS)
+ const [settings, setSettings] = useState(DEFAULT_THEME_SETTINGS)
+ const [showPreview, setShowPreview] = useState(false)
+
+ useEffect(() => {
+ if (theme) {
+ setName(theme.name)
+ setIsPublic(theme.isPublic)
+ setColors(theme.colors)
+ setSettings(theme.settings)
+ } else {
+ setName('')
+ setIsPublic(false)
+ setColors(DEFAULT_THEME_COLORS)
+ setSettings(DEFAULT_THEME_SETTINGS)
+ }
+ }, [theme, open])
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!name.trim()) {
+ return
+ }
+ onSave({
+ name: name.trim(),
+ isPublic,
+ colors,
+ settings,
+ })
+ }
+
+ const updateColor = (key: keyof ThemeColors, value: string) => {
+ setColors((prev) => ({ ...prev, [key]: value }))
+ }
+
+ const updateSetting = (key: keyof ThemeSettings, value: string) => {
+ setSettings((prev) => ({ ...prev, [key]: value }))
+ }
+
+ return (
+
+ )
+}
diff --git a/admin/src/components/layout/Layout.tsx b/admin/src/components/layout/Layout.tsx
index 75b0df0..1327124 100644
--- a/admin/src/components/layout/Layout.tsx
+++ b/admin/src/components/layout/Layout.tsx
@@ -6,9 +6,11 @@ import {
LayoutDashboard,
Package,
Users,
+ Palette,
LogOut,
Menu,
- X
+ X,
+ DoorOpen
} from 'lucide-react'
import { useState } from 'react'
@@ -20,6 +22,8 @@ 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) {
diff --git a/admin/src/pages/PacksPage.tsx b/admin/src/pages/PacksPage.tsx
index 16a5d65..36a50c4 100644
--- a/admin/src/pages/PacksPage.tsx
+++ b/admin/src/pages/PacksPage.tsx
@@ -41,9 +41,10 @@ import {
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
-import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
+import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, Download, FileJson } from 'lucide-react'
import { GameQuestionsManager } from '@/components/GameQuestionsManager'
import { GameQuestionEditorDialog } from '@/components/GameQuestionEditorDialog'
+import { PackImportDialog } from '@/components/PackImportDialog'
export default function PacksPage() {
const queryClient = useQueryClient()
@@ -55,6 +56,9 @@ export default function PacksPage() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [packToDelete, setPackToDelete] = useState(null)
+ // Import dialog state
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
+
// Question editor state
const [isQuestionEditorOpen, setIsQuestionEditorOpen] = useState(false)
const [editingQuestion, setEditingQuestion] = useState<{
@@ -278,6 +282,41 @@ export default function PacksPage() {
setPage(1) // Reset to first page when searching
}
+ const downloadJson = (data: object, filename: string) => {
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }
+
+ const handleDownloadTemplate = async () => {
+ try {
+ const template = await packsApi.getTemplate()
+ downloadJson(template, 'pack-template.json')
+ toast.success('Template downloaded')
+ } catch (error) {
+ const errorMessage = isPacksApiError(error) ? error.message : 'Failed to download template'
+ toast.error(errorMessage)
+ }
+ }
+
+ const handleExportPack = async (pack: CardPackPreviewDto) => {
+ try {
+ const exportedPack = await packsApi.exportPack(pack.id)
+ const safeName = pack.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()
+ downloadJson(exportedPack, `pack-${safeName}.json`)
+ toast.success('Pack exported successfully')
+ } catch (error) {
+ const errorMessage = isPacksApiError(error) ? error.message : 'Failed to export pack'
+ toast.error(errorMessage)
+ }
+ }
+
if (error) {
const errorMessage = isPacksApiError(error)
? error.message
@@ -317,10 +356,20 @@ export default function PacksPage() {
View, create, edit and delete card packs
-
+
+
+
+
+
{/* Search and Filters */}
@@ -394,13 +443,23 @@ export default function PacksPage() {
variant="ghost"
size="sm"
onClick={() => openEditDialog(pack)}
+ title="Edit pack"
>
+
@@ -565,6 +624,16 @@ export default function PacksPage() {
+
+ {/* Import Dialog */}
+ {
+ queryClient.invalidateQueries({ queryKey: ['packs'] })
+ setIsImportDialogOpen(false)
+ }}
+ onClose={() => setIsImportDialogOpen(false)}
+ />
)
}
diff --git a/admin/src/pages/RoomsPage.tsx b/admin/src/pages/RoomsPage.tsx
new file mode 100644
index 0000000..7577819
--- /dev/null
+++ b/admin/src/pages/RoomsPage.tsx
@@ -0,0 +1,354 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { roomsApi, type RoomDto } from '@/api/rooms'
+import type { AxiosError } from 'axios'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Badge } from '@/components/ui/badge'
+import { Search, Plus, Trash2, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'
+import { CreateAdminRoomDialog } from '@/components/CreateAdminRoomDialog'
+
+export default function RoomsPage() {
+ const queryClient = useQueryClient()
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState<'WAITING' | 'PLAYING' | 'FINISHED' | ''>('')
+ const [createDialogOpen, setCreateDialogOpen] = useState(false)
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
+ const [roomToDelete, setRoomToDelete] = useState(null)
+
+ const limit = 20
+
+ // Fetch rooms
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['admin', 'rooms', page, statusFilter],
+ queryFn: () => roomsApi.getRooms({
+ page,
+ limit,
+ status: statusFilter || undefined,
+ }),
+ })
+
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: (roomId: string) => roomsApi.deleteRoom(roomId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'rooms'] })
+ toast.success('Room deleted successfully')
+ setIsDeleteDialogOpen(false)
+ setRoomToDelete(null)
+ },
+ onError: (error: unknown) => {
+ const axiosError = error as AxiosError<{ message?: string }>
+ toast.error(axiosError.response?.data?.message || 'Failed to delete room')
+ },
+ })
+
+ const handleDelete = (room: RoomDto) => {
+ setRoomToDelete(room)
+ setIsDeleteDialogOpen(true)
+ }
+
+ const confirmDelete = () => {
+ if (roomToDelete?.id) {
+ deleteMutation.mutate(roomToDelete.id)
+ }
+ }
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case 'WAITING':
+ return Waiting
+ case 'PLAYING':
+ return Playing
+ case 'FINISHED':
+ return Finished
+ default:
+ return {status}
+ }
+ }
+
+ const filteredRooms = data?.rooms.filter(room =>
+ search === '' ||
+ room.code.toLowerCase().includes(search.toLowerCase()) ||
+ room.host.name?.toLowerCase().includes(search.toLowerCase()) ||
+ room.host.email?.toLowerCase().includes(search.toLowerCase())
+ ) || []
+
+ if (error) {
+ return (
+
+
+
Rooms Management
+
Error loading rooms
+
+
+
+ Failed to load rooms. Please try again later.
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Rooms Management
+
+ View and manage game rooms
+
+
+
+
+
+ {/* Filters */}
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* Rooms Table */}
+
+
+ All Rooms ({data?.total || 0})
+
+ Manage game rooms and their settings
+
+
+
+ {isLoading ? (
+ Loading rooms...
+ ) : (
+ <>
+
+
+
+ Code
+ Status
+ Host
+ Theme
+ Pack
+ Players
+ Created
+ Active Period
+ Actions
+
+
+
+ {filteredRooms.length === 0 ? (
+
+
+ No rooms found
+
+
+ ) : (
+ filteredRooms.map((room) => (
+
+
+ {room.code}
+ {room.isAdminRoom && (
+ Admin
+ )}
+
+ {getStatusBadge(room.status)}
+
+
+
{room.host.name || 'No name'}
+ {room.host.email && (
+
{room.host.email}
+ )}
+
+
+
+ {room.theme ? (
+ {room.theme.name}
+ ) : (
+ Default
+ )}
+
+
+ {room.questionPack ? (
+ {room.questionPack.name}
+ ) : (
+ -
+ )}
+
+
+ {room._count?.participants || 0} / {room.maxPlayers}
+
+
+ {new Date(room.createdAt).toLocaleDateString()}
+
+
+ {room.activeFrom && room.activeTo ? (
+
+
{new Date(room.activeFrom).toLocaleDateString()}
+
to {new Date(room.activeTo).toLocaleDateString()}
+
+ ) : (
+ -
+ )}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+ {/* Pagination */}
+ {data && data.totalPages > 1 && (
+
+
+ Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} rooms
+
+
+
+
+ Page {page} of {data.totalPages}
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ {/* Create Room Dialog */}
+ {createDialogOpen && (
+
setCreateDialogOpen(false)}
+ onSuccess={() => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'rooms'] })
+ setCreateDialogOpen(false)
+ }}
+ />
+ )}
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Delete Room
+
+ Are you sure you want to delete the room "{roomToDelete?.code}"? This action cannot be undone.
+
+
+
+ Cancel
+
+ {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+ )
+}
+
diff --git a/admin/src/pages/ThemesPage.tsx b/admin/src/pages/ThemesPage.tsx
new file mode 100644
index 0000000..a1762c7
--- /dev/null
+++ b/admin/src/pages/ThemesPage.tsx
@@ -0,0 +1,381 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import {
+ themesApi,
+ isThemesApiError,
+ DEFAULT_THEME_COLORS,
+ DEFAULT_THEME_SETTINGS,
+ type ThemePreview,
+ type ThemeColors,
+ type ThemeSettings,
+ type CreateThemeDto,
+} from '@/api/themes'
+import { formatApiError } from '@/lib/error-utils'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Label } from '@/components/ui/label'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Eye } from 'lucide-react'
+import { ThemeEditorDialog } from '@/components/ThemeEditorDialog'
+
+export default function ThemesPage() {
+ const queryClient = useQueryClient()
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState('')
+ const [showPrivate, setShowPrivate] = useState(true)
+ const [isEditorOpen, setIsEditorOpen] = useState(false)
+ const [editingTheme, setEditingTheme] = useState(null)
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
+ const [themeToDelete, setThemeToDelete] = useState(null)
+
+ const limit = 20
+
+ // Fetch themes
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['themes', page, search, showPrivate],
+ queryFn: () => themesApi.getThemes({
+ page,
+ limit,
+ search,
+ isPublic: showPrivate ? undefined : true,
+ }),
+ retry: (failureCount, error) => {
+ if (isThemesApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
+ return false
+ }
+ return failureCount < 2
+ },
+ })
+
+ // Mutations
+ const createMutation = useMutation({
+ mutationFn: (theme: CreateThemeDto) => themesApi.createTheme(theme),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['themes'] })
+ toast.success('Theme created successfully')
+ closeEditor()
+ },
+ onError: (error: unknown) => {
+ const errorMessage = isThemesApiError(error) ? error.message : 'Failed to create theme'
+ toast.error(errorMessage)
+ },
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, theme }: { id: string; theme: Partial }) =>
+ themesApi.updateTheme(id, theme),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['themes'] })
+ toast.success('Theme updated successfully')
+ closeEditor()
+ },
+ onError: (error: unknown) => {
+ const errorMessage = isThemesApiError(error) ? error.message : 'Failed to update theme'
+ toast.error(errorMessage)
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (themeId: string) => themesApi.deleteTheme(themeId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['themes'] })
+ toast.success('Theme deleted successfully')
+ setIsDeleteDialogOpen(false)
+ setThemeToDelete(null)
+ },
+ onError: (error: unknown) => {
+ const errorMessage = isThemesApiError(error) ? error.message : 'Failed to delete theme'
+ toast.error(errorMessage)
+ },
+ })
+
+ const openCreateEditor = () => {
+ setEditingTheme(null)
+ setIsEditorOpen(true)
+ }
+
+ const openEditEditor = (theme: ThemePreview) => {
+ setEditingTheme(theme)
+ setIsEditorOpen(true)
+ }
+
+ const closeEditor = () => {
+ setIsEditorOpen(false)
+ setEditingTheme(null)
+ }
+
+ const handleSave = (formData: {
+ name: string
+ isPublic: boolean
+ colors: ThemeColors
+ settings: ThemeSettings
+ }) => {
+ if (editingTheme) {
+ updateMutation.mutate({
+ id: editingTheme.id,
+ theme: formData,
+ })
+ } else {
+ createMutation.mutate(formData)
+ }
+ }
+
+ const handleDelete = (theme: ThemePreview) => {
+ setThemeToDelete(theme)
+ setIsDeleteDialogOpen(true)
+ }
+
+ const confirmDelete = () => {
+ if (themeToDelete) {
+ deleteMutation.mutate(themeToDelete.id)
+ }
+ }
+
+ const handleSearch = (value: string) => {
+ setSearch(value)
+ setPage(1)
+ }
+
+ if (error) {
+ const errorMessage = isThemesApiError(error) ? error.message : formatApiError(error)
+ return (
+
+
+
Themes Management
+
Error loading themes
+
+
+
+
+
Failed to load themes
+
{errorMessage}
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Themes Management
+
Create and manage game themes
+
+
+
+
+ {/* Search and Filters */}
+
+
+
+
+
+ handleSearch(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+ setShowPrivate(checked as boolean)}
+ />
+
+
+
+
+
+
+ {/* Themes Table */}
+
+
+ All Themes ({data?.total || 0})
+ Manage theme colors and settings
+
+
+ {isLoading ? (
+ Loading themes...
+ ) : (
+ <>
+
+
+
+ Preview
+ Name
+ Public
+ Creator
+ Created
+ Actions
+
+
+
+ {(data?.themes || []).map((theme) => (
+
+
+
+
+
+ {theme.name}
+
+
+
+ {theme.isPublic ? 'Public' : 'Private'}
+
+
+ {theme.creator?.name || 'Unknown'}
+
+ {new Date(theme.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ {/* Pagination */}
+ {data && data.totalPages > 1 && (
+
+
+ Showing {(page - 1) * limit + 1} to{' '}
+ {Math.min(page * limit, data.total)} of {data.total} themes
+
+
+
+
+ Page {page} of {data.totalPages}
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ {/* Theme Editor Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Delete Theme
+
+ Are you sure you want to delete the theme "{themeToDelete?.name}"? This
+ action cannot be undone.
+
+
+
+ Cancel
+
+ {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+ )
+}
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index a840a62..5cbc4fa 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -36,7 +36,7 @@ model Room {
status RoomStatus @default(WAITING)
hostId String
createdAt DateTime @default(now())
- expiresAt DateTime
+ expiresAt DateTime?
// Настройки
maxPlayers Int @default(10)
@@ -47,6 +47,14 @@ model Room {
autoAdvance Boolean @default(false)
voiceMode Boolean @default(false) // Голосовой режим
+ // Админские комнаты
+ isAdminRoom Boolean @default(false)
+ customCode String? // Кастомный код вместо random
+ activeFrom DateTime? // Комната доступна с этого времени
+ activeTo DateTime? // Комната доступна до этого времени
+ themeId String? // FK на Theme
+ uiControls Json? // { allowThemeChange, allowPackChange, allowNameChange, allowScoreEdit }
+
// Состояние игры
currentQuestionIndex Int @default(0)
currentQuestionId String? // UUID текущего вопроса
@@ -66,6 +74,7 @@ model Room {
questionPack QuestionPack? @relation(fields: [questionPackId], references: [id])
roomPack RoomPack?
gameHistory GameHistory?
+ theme Theme? @relation(fields: [themeId], references: [id])
}
enum RoomStatus {
@@ -187,4 +196,5 @@ model Theme {
settings Json // { shadowSm, shadowMd, blurAmount, borderRadius, animationSpeed, etc. }
creator User @relation(fields: [createdBy], references: [id])
+ rooms Room[]
}
diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts
index f4c3aaa..eb885a9 100644
--- a/backend/src/admin/admin.module.ts
+++ b/backend/src/admin/admin.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PrismaModule } from '../prisma/prisma.module';
+import { RoomPackModule } from '../room-pack/room-pack.module';
// Auth
import { AdminAuthController } from './auth/admin-auth.controller';
@@ -27,6 +28,10 @@ import { AdminAnalyticsService } from './analytics/admin-analytics.service';
import { AdminGameHistoryController } from './game-history/admin-game-history.controller';
import { AdminGameHistoryService } from './game-history/admin-game-history.service';
+// Themes
+import { AdminThemesController } from './themes/admin-themes.controller';
+import { AdminThemesService } from './themes/admin-themes.service';
+
// Guards
import { AdminAuthGuard } from './guards/admin-auth.guard';
import { AdminGuard } from './guards/admin.guard';
@@ -34,6 +39,7 @@ import { AdminGuard } from './guards/admin.guard';
@Module({
imports: [
PrismaModule,
+ RoomPackModule,
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
@@ -54,6 +60,7 @@ import { AdminGuard } from './guards/admin.guard';
AdminPacksController,
AdminAnalyticsController,
AdminGameHistoryController,
+ AdminThemesController,
],
providers: [
AdminAuthService,
@@ -62,6 +69,7 @@ import { AdminGuard } from './guards/admin.guard';
AdminPacksService,
AdminAnalyticsService,
AdminGameHistoryService,
+ AdminThemesService,
AdminAuthGuard,
AdminGuard,
],
diff --git a/backend/src/admin/packs/admin-packs.controller.ts b/backend/src/admin/packs/admin-packs.controller.ts
index 638f0f7..c742040 100644
--- a/backend/src/admin/packs/admin-packs.controller.ts
+++ b/backend/src/admin/packs/admin-packs.controller.ts
@@ -29,31 +29,13 @@ export class AdminPacksController {
return this.adminPacksService.findAll(filters);
}
- @Get(':id')
- findOne(@Param('id') id: string) {
- return this.adminPacksService.findOne(id);
- }
-
- @Post()
- create(@Body() createPackDto: CreatePackDto, @Request() req) {
- return this.adminPacksService.create(createPackDto, req.user.sub);
- }
-
- @Patch(':id')
- update(@Param('id') id: string, @Body() updatePackDto: UpdatePackDto) {
- return this.adminPacksService.update(id, updatePackDto);
- }
-
- @Delete(':id')
- remove(@Param('id') id: string) {
- return this.adminPacksService.remove(id);
- }
-
+ // Static routes must come BEFORE dynamic :id routes
@Get('export/template')
getTemplate() {
return {
templateVersion: '1.0',
- instructions: 'Fill in your questions below. Each question must have a "question" field and an "answers" array with "text" and "points" fields.',
+ instructions:
+ 'Fill in your questions below. Each question must have a "question" field and an "answers" array with "text" and "points" fields.',
questions: [
{
question: 'Your question here',
@@ -69,11 +51,6 @@ export class AdminPacksController {
};
}
- @Get(':id/export')
- async exportPack(@Param('id') id: string) {
- return this.adminPacksService.exportPack(id);
- }
-
@Post('import')
async importPack(@Body() importPackDto: ImportPackDto, @Request() req) {
// Validate question structure
@@ -99,4 +76,30 @@ export class AdminPacksController {
return this.adminPacksService.create(importPackDto, req.user.sub);
}
+
+ // Dynamic :id routes come after static routes
+ @Get(':id')
+ findOne(@Param('id') id: string) {
+ return this.adminPacksService.findOne(id);
+ }
+
+ @Get(':id/export')
+ async exportPack(@Param('id') id: string) {
+ return this.adminPacksService.exportPack(id);
+ }
+
+ @Post()
+ create(@Body() createPackDto: CreatePackDto, @Request() req) {
+ return this.adminPacksService.create(createPackDto, req.user.sub);
+ }
+
+ @Patch(':id')
+ update(@Param('id') id: string, @Body() updatePackDto: UpdatePackDto) {
+ return this.adminPacksService.update(id, updatePackDto);
+ }
+
+ @Delete(':id')
+ remove(@Param('id') id: string) {
+ return this.adminPacksService.remove(id);
+ }
}
diff --git a/backend/src/admin/rooms/admin-rooms.controller.ts b/backend/src/admin/rooms/admin-rooms.controller.ts
index de2dad9..7767ca0 100644
--- a/backend/src/admin/rooms/admin-rooms.controller.ts
+++ b/backend/src/admin/rooms/admin-rooms.controller.ts
@@ -1,13 +1,16 @@
import {
Controller,
Get,
+ Post,
Delete,
Param,
Query,
+ Body,
UseGuards,
} from '@nestjs/common';
import { AdminRoomsService } from './admin-rooms.service';
import { RoomFiltersDto } from './dto/room-filters.dto';
+import { CreateAdminRoomDto } from './dto/create-admin-room.dto';
import { AdminAuthGuard } from '../guards/admin-auth.guard';
import { AdminGuard } from '../guards/admin.guard';
@@ -26,6 +29,11 @@ export class AdminRoomsController {
return this.adminRoomsService.findOne(id);
}
+ @Post()
+ create(@Body() dto: CreateAdminRoomDto) {
+ return this.adminRoomsService.createAdminRoom(dto);
+ }
+
@Delete(':id')
remove(@Param('id') id: string) {
return this.adminRoomsService.remove(id);
diff --git a/backend/src/admin/rooms/admin-rooms.service.ts b/backend/src/admin/rooms/admin-rooms.service.ts
index 9ec2973..dfdb085 100644
--- a/backend/src/admin/rooms/admin-rooms.service.ts
+++ b/backend/src/admin/rooms/admin-rooms.service.ts
@@ -1,10 +1,18 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RoomFiltersDto } from './dto/room-filters.dto';
+import { CreateAdminRoomDto } from './dto/create-admin-room.dto';
+import { RoomPackService } from '../../room-pack/room-pack.service';
+import { customAlphabet } from 'nanoid';
+
+const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
@Injectable()
export class AdminRoomsService {
- constructor(private prisma: PrismaService) {}
+ constructor(
+ private prisma: PrismaService,
+ private roomPackService: RoomPackService,
+ ) {}
async findAll(filters: RoomFiltersDto) {
const { status, dateFrom, dateTo, page = 1, limit = 10 } = filters;
@@ -128,4 +136,92 @@ export class AdminRoomsService {
return { message: 'Room deleted successfully' };
}
+
+ async createAdminRoom(dto: CreateAdminRoomDto) {
+ // Проверить что customCode не занят (если указан)
+ if (dto.customCode) {
+ const existing = await this.prisma.room.findUnique({
+ where: { code: dto.customCode },
+ });
+ if (existing) {
+ throw new BadRequestException('Code already in use');
+ }
+ }
+
+ const code = dto.customCode || nanoid();
+
+ // НЕ устанавливать expiresAt если указаны activeFrom/activeTo
+ const expiresAt = (dto.activeFrom || dto.activeTo)
+ ? null
+ : new Date(Date.now() + 24 * 60 * 60 * 1000);
+
+ // Подготовить настройки игры
+ const gameSettings = dto.settings || {};
+ const {
+ maxPlayers,
+ allowSpectators,
+ timerEnabled,
+ timerDuration,
+ ...otherSettings
+ } = gameSettings;
+
+ const room = await this.prisma.room.create({
+ data: {
+ code,
+ hostId: dto.hostId,
+ expiresAt,
+ isAdminRoom: true,
+ customCode: dto.customCode || null,
+ activeFrom: dto.activeFrom ? new Date(dto.activeFrom) : null,
+ activeTo: dto.activeTo ? new Date(dto.activeTo) : null,
+ themeId: dto.themeId || null,
+ uiControls: dto.uiControls || null,
+ questionPackId: dto.questionPackId || null,
+ maxPlayers: maxPlayers || 10,
+ allowSpectators: allowSpectators !== undefined ? allowSpectators : true,
+ timerEnabled: timerEnabled || false,
+ timerDuration: timerDuration || 30,
+ ...otherSettings,
+ },
+ include: {
+ host: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ theme: {
+ select: {
+ id: true,
+ name: true,
+ isPublic: true,
+ },
+ },
+ questionPack: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ // Создать участника-хоста
+ const hostName = dto.hostName || room.host.name || 'Ведущий';
+ await this.prisma.participant.create({
+ data: {
+ userId: dto.hostId,
+ roomId: room.id,
+ name: hostName,
+ role: 'HOST',
+ },
+ });
+
+ // Создать RoomPack
+ await this.roomPackService.create(room.id, dto.questionPackId);
+
+ // Вернуть полную информацию о комнате
+ return this.findOne(room.id);
+ }
}
diff --git a/backend/src/admin/rooms/dto/create-admin-room.dto.ts b/backend/src/admin/rooms/dto/create-admin-room.dto.ts
new file mode 100644
index 0000000..2c22405
--- /dev/null
+++ b/backend/src/admin/rooms/dto/create-admin-room.dto.ts
@@ -0,0 +1,94 @@
+import {
+ IsString,
+ IsOptional,
+ IsBoolean,
+ IsDateString,
+ IsObject,
+ ValidateNested,
+ IsInt,
+ Min,
+ Max,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+
+class UiControlsDto {
+ @IsOptional()
+ @IsBoolean()
+ allowThemeChange?: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ allowPackChange?: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ allowNameChange?: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ allowScoreEdit?: boolean;
+}
+
+class GameSettingsDto {
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(100)
+ maxPlayers?: number;
+
+ @IsOptional()
+ @IsBoolean()
+ allowSpectators?: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ timerEnabled?: boolean;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(300)
+ timerDuration?: number;
+}
+
+export class CreateAdminRoomDto {
+ @IsString()
+ hostId: string;
+
+ @IsOptional()
+ @IsString()
+ hostName?: string;
+
+ @IsOptional()
+ @IsString()
+ customCode?: string;
+
+ @IsOptional()
+ @IsDateString()
+ activeFrom?: string;
+
+ @IsOptional()
+ @IsDateString()
+ activeTo?: string;
+
+ @IsOptional()
+ @IsString()
+ themeId?: string;
+
+ @IsOptional()
+ @IsString()
+ questionPackId?: string;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => UiControlsDto)
+ uiControls?: UiControlsDto;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => GameSettingsDto)
+ settings?: GameSettingsDto;
+}
+
diff --git a/backend/src/admin/themes/admin-themes.controller.ts b/backend/src/admin/themes/admin-themes.controller.ts
new file mode 100644
index 0000000..93258b6
--- /dev/null
+++ b/backend/src/admin/themes/admin-themes.controller.ts
@@ -0,0 +1,49 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Patch,
+ Delete,
+ Param,
+ Query,
+ Body,
+ UseGuards,
+ Request,
+} from '@nestjs/common';
+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 { AdminAuthGuard } from '../guards/admin-auth.guard';
+import { AdminGuard } from '../guards/admin.guard';
+
+@Controller('api/admin/themes')
+@UseGuards(AdminAuthGuard, AdminGuard)
+export class AdminThemesController {
+ constructor(private readonly adminThemesService: AdminThemesService) {}
+
+ @Get()
+ findAll(@Query() filters: ThemeFiltersDto) {
+ return this.adminThemesService.findAll(filters);
+ }
+
+ @Get(':id')
+ findOne(@Param('id') id: string) {
+ return this.adminThemesService.findOne(id);
+ }
+
+ @Post()
+ create(@Body() createThemeDto: CreateThemeDto, @Request() req) {
+ return this.adminThemesService.create(createThemeDto, req.user.sub);
+ }
+
+ @Patch(':id')
+ update(@Param('id') id: string, @Body() updateThemeDto: UpdateThemeDto) {
+ return this.adminThemesService.update(id, updateThemeDto);
+ }
+
+ @Delete(':id')
+ remove(@Param('id') id: string) {
+ return this.adminThemesService.remove(id);
+ }
+}
diff --git a/backend/src/admin/themes/admin-themes.service.ts b/backend/src/admin/themes/admin-themes.service.ts
new file mode 100644
index 0000000..f5d137c
--- /dev/null
+++ b/backend/src/admin/themes/admin-themes.service.ts
@@ -0,0 +1,151 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { PrismaService } from '../../prisma/prisma.service';
+import { ThemeFiltersDto } from './dto/theme-filters.dto';
+import { CreateThemeDto } from './dto/create-theme.dto';
+import { UpdateThemeDto } from './dto/update-theme.dto';
+
+@Injectable()
+export class AdminThemesService {
+ constructor(private prisma: PrismaService) {}
+
+ async findAll(filters: ThemeFiltersDto) {
+ const { search, isPublic, page = 1, limit = 10 } = filters;
+ const skip = (page - 1) * limit;
+
+ const where: any = {};
+
+ if (search) {
+ where.name = { contains: search, mode: 'insensitive' };
+ }
+
+ if (isPublic !== undefined) {
+ where.isPublic = isPublic;
+ }
+
+ const [themes, total] = await Promise.all([
+ this.prisma.theme.findMany({
+ where,
+ skip,
+ take: limit,
+ select: {
+ id: true,
+ name: true,
+ isPublic: true,
+ colors: true,
+ settings: true,
+ createdAt: true,
+ updatedAt: true,
+ creator: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ }),
+ this.prisma.theme.count({ where }),
+ ]);
+
+ return {
+ themes,
+ total,
+ page,
+ limit,
+ totalPages: Math.ceil(total / limit),
+ };
+ }
+
+ async findOne(id: string) {
+ const theme = await this.prisma.theme.findUnique({
+ where: { id },
+ include: {
+ creator: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ if (!theme) {
+ throw new NotFoundException('Theme not found');
+ }
+
+ return theme;
+ }
+
+ async create(createThemeDto: CreateThemeDto, createdBy: string) {
+ return this.prisma.theme.create({
+ data: {
+ ...createThemeDto,
+ createdBy,
+ },
+ include: {
+ creator: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+ }
+
+ async update(id: string, updateThemeDto: UpdateThemeDto) {
+ const theme = await this.prisma.theme.findUnique({
+ where: { id },
+ });
+
+ if (!theme) {
+ throw new NotFoundException('Theme not found');
+ }
+
+ return this.prisma.theme.update({
+ where: { id },
+ data: updateThemeDto,
+ include: {
+ creator: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+ }
+
+ async remove(id: string) {
+ const theme = await this.prisma.theme.findUnique({
+ where: { id },
+ });
+
+ if (!theme) {
+ throw new NotFoundException('Theme not found');
+ }
+
+ await this.prisma.theme.delete({
+ where: { id },
+ });
+
+ return { message: 'Theme deleted successfully' };
+ }
+
+ async findPublic() {
+ return this.prisma.theme.findMany({
+ where: { isPublic: true },
+ select: {
+ id: true,
+ name: true,
+ colors: true,
+ settings: true,
+ },
+ orderBy: { name: 'asc' },
+ });
+ }
+}
diff --git a/backend/src/admin/themes/dto/create-theme.dto.ts b/backend/src/admin/themes/dto/create-theme.dto.ts
new file mode 100644
index 0000000..c5049c4
--- /dev/null
+++ b/backend/src/admin/themes/dto/create-theme.dto.ts
@@ -0,0 +1,91 @@
+import {
+ IsString,
+ IsBoolean,
+ IsOptional,
+ IsObject,
+ ValidateNested,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class ThemeColorsDto {
+ @IsString()
+ bgPrimary: string; // Gradient or solid
+
+ @IsString()
+ bgOverlay: string;
+
+ @IsString()
+ bgCard: string;
+
+ @IsString()
+ bgCardHover: string;
+
+ @IsString()
+ textPrimary: string;
+
+ @IsString()
+ textSecondary: string;
+
+ @IsString()
+ textGlow: string;
+
+ @IsString()
+ accentPrimary: string;
+
+ @IsString()
+ accentSecondary: string;
+
+ @IsString()
+ accentSuccess: string;
+
+ @IsString()
+ borderColor: string;
+
+ @IsString()
+ borderGlow: string;
+}
+
+export class ThemeSettingsDto {
+ @IsString()
+ shadowSm: string;
+
+ @IsString()
+ shadowMd: string;
+
+ @IsString()
+ shadowLg: string;
+
+ @IsString()
+ blurAmount: string;
+
+ @IsString()
+ borderRadiusSm: string;
+
+ @IsString()
+ borderRadiusMd: string;
+
+ @IsString()
+ borderRadiusLg: string;
+
+ @IsString()
+ animationSpeed: string;
+}
+
+export class CreateThemeDto {
+ @IsString()
+ name: string;
+
+ @IsBoolean()
+ @IsOptional()
+ isPublic?: boolean;
+
+ @IsObject()
+ @ValidateNested()
+ @Type(() => ThemeColorsDto)
+ colors: ThemeColorsDto;
+
+ @IsObject()
+ @ValidateNested()
+ @Type(() => ThemeSettingsDto)
+ settings: ThemeSettingsDto;
+}
diff --git a/backend/src/admin/themes/dto/theme-filters.dto.ts b/backend/src/admin/themes/dto/theme-filters.dto.ts
new file mode 100644
index 0000000..e9622ce
--- /dev/null
+++ b/backend/src/admin/themes/dto/theme-filters.dto.ts
@@ -0,0 +1,25 @@
+import { IsOptional, IsString, IsBoolean, IsInt, Min } from 'class-validator';
+import { Transform, Type } from 'class-transformer';
+
+export class ThemeFiltersDto {
+ @IsOptional()
+ @IsString()
+ search?: string;
+
+ @IsOptional()
+ @Transform(({ value }) => value === 'true' || value === true)
+ @IsBoolean()
+ isPublic?: boolean;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page?: number = 1;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ limit?: number = 10;
+}
diff --git a/backend/src/admin/themes/dto/update-theme.dto.ts b/backend/src/admin/themes/dto/update-theme.dto.ts
new file mode 100644
index 0000000..0d5adaa
--- /dev/null
+++ b/backend/src/admin/themes/dto/update-theme.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/mapped-types';
+import { CreateThemeDto } from './create-theme.dto';
+
+export class UpdateThemeDto extends PartialType(CreateThemeDto) {}
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index 58e2480..9429371 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -10,6 +10,7 @@ import { GameModule } from './game/game.module';
import { StatsModule } from './stats/stats.module';
import { VoiceModule } from './voice/voice.module';
import { AdminModule } from './admin/admin.module';
+import { ThemesModule } from './themes/themes.module';
@Module({
imports: [
@@ -22,6 +23,7 @@ import { AdminModule } from './admin/admin.module';
StatsModule,
VoiceModule,
AdminModule,
+ ThemesModule,
],
controllers: [AppController],
providers: [AppService],
diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts
index bec7169..e4fd271 100644
--- a/backend/src/game/game.gateway.ts
+++ b/backend/src/game/game.gateway.ts
@@ -144,6 +144,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}
await this.broadcastFullState(payload.roomCode);
+
+ // Явно отправить событие начала игры для перенаправления всех игроков
+ this.server.to(payload.roomCode).emit('gameStarted', {
+ roomId: payload.roomId,
+ roomCode: payload.roomCode,
+ status: 'PLAYING'
+ });
}
@SubscribeMessage('playerAction')
@@ -529,6 +536,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
+ // Проверить uiControls
+ const roomForControls = await this.prisma.room.findUnique({
+ where: { id: payload.roomId },
+ }) as any;
+
+ const uiControls = roomForControls?.uiControls as { allowPackChange?: boolean } | null;
+ if (uiControls && uiControls.allowPackChange === false) {
+ client.emit('error', { message: 'Pack editing is disabled for this room' });
+ return;
+ }
+
// Обновляем вопросы через service (который добавит UUID если нужно)
await this.roomsService.updateRoomPack(payload.roomId, payload.questions);
@@ -589,6 +607,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
+ // Проверить uiControls
+ const roomForControls = await this.prisma.room.findUnique({
+ where: { id: payload.roomId },
+ }) as any;
+
+ const uiControls = roomForControls?.uiControls as { allowPackChange?: boolean } | null;
+ if (uiControls && uiControls.allowPackChange === false) {
+ client.emit('error', { message: 'Pack editing is disabled for this room' });
+ return;
+ }
+
await this.roomPackService.importQuestions(payload.roomId, payload.sourcePackId, payload.questionIndices);
await this.broadcastFullState(payload.roomCode);
}
@@ -623,6 +652,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
+ // Проверить uiControls
+ const roomForControls = await this.prisma.room.findUnique({
+ where: { id: payload.roomId },
+ }) as any;
+
+ const uiControls = roomForControls?.uiControls as { allowNameChange?: boolean } | null;
+ if (uiControls && uiControls.allowNameChange === false) {
+ client.emit('error', { message: 'Name editing is disabled for this room' });
+ return;
+ }
+
if (!payload.newName || payload.newName.trim().length === 0) {
client.emit('error', { message: 'Name cannot be empty' });
return;
@@ -655,6 +695,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
+ // Проверить uiControls
+ const roomForControls = await this.prisma.room.findUnique({
+ where: { id: payload.roomId },
+ }) as any;
+
+ const uiControls = roomForControls?.uiControls as { allowScoreEdit?: boolean } | null;
+ if (uiControls && uiControls.allowScoreEdit === false) {
+ client.emit('error', { message: 'Score editing is disabled for this room' });
+ return;
+ }
+
if (typeof payload.newScore !== 'number' || isNaN(payload.newScore)) {
client.emit('error', { message: 'Invalid score value' });
return;
diff --git a/backend/src/rooms/rooms.controller.ts b/backend/src/rooms/rooms.controller.ts
index 933181a..c398cf5 100644
--- a/backend/src/rooms/rooms.controller.ts
+++ b/backend/src/rooms/rooms.controller.ts
@@ -6,8 +6,8 @@ export class RoomsController {
constructor(private roomsService: RoomsService) {}
@Post()
- async createRoom(@Body() dto: { hostId: string; questionPackId?: string; settings?: any }) {
- return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings);
+ async createRoom(@Body() dto: { hostId: string; questionPackId?: string; settings?: any; hostName?: string }) {
+ return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings, dto.hostName);
}
@Get(':code')
diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts
index 74421f4..c0f6336 100644
--- a/backend/src/rooms/rooms.service.ts
+++ b/backend/src/rooms/rooms.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, Inject, forwardRef } from '@nestjs/common';
+import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { customAlphabet } from 'nanoid';
import { RoomEventsService } from '../game/room-events.service';
@@ -15,7 +15,7 @@ export class RoomsService {
private roomPackService: RoomPackService,
) {}
- async createRoom(hostId: string, questionPackId?: string, settings?: any) {
+ async createRoom(hostId: string, questionPackId?: string, settings?: any, hostName?: string) {
const code = nanoid();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
@@ -24,6 +24,9 @@ export class RoomsService {
if ('questionPackId' in cleanSettings) {
delete cleanSettings.questionPackId;
}
+ if ('hostName' in cleanSettings) {
+ delete cleanSettings.hostName;
+ }
const room = await this.prisma.room.create({
data: {
@@ -39,11 +42,14 @@ export class RoomsService {
},
});
+ // Используем переданное имя хоста или имя пользователя или дефолт "Ведущий"
+ const finalHostName = hostName?.trim() || room.host.name || 'Ведущий';
+
await this.prisma.participant.create({
data: {
userId: hostId,
roomId: room.id,
- name: room.host.name || 'Host',
+ name: finalHostName,
role: 'HOST',
},
});
@@ -56,7 +62,7 @@ export class RoomsService {
}
async getRoomByCode(code: string) {
- return this.prisma.room.findUnique({
+ const room = await this.prisma.room.findUnique({
where: { code },
include: {
host: true,
@@ -65,8 +71,24 @@ export class RoomsService {
},
questionPack: true,
roomPack: true,
+ theme: true,
},
});
+
+ if (!room) {
+ throw new NotFoundException('Room not found');
+ }
+
+ // Проверить период активности
+ const now = new Date();
+ if (room.activeFrom && now < room.activeFrom) {
+ throw new BadRequestException('Room not yet active');
+ }
+ if (room.activeTo && now > room.activeTo) {
+ throw new BadRequestException('Room is no longer active');
+ }
+
+ return room;
}
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
@@ -114,11 +136,32 @@ export class RoomsService {
}
async updateQuestionPack(roomId: string, questionPackId: string) {
- return this.prisma.room.update({
+ // Получаем вопросы из выбранного пака
+ const sourcePack = await this.prisma.questionPack.findUnique({
+ where: { id: questionPackId },
+ select: { questions: true },
+ });
+
+ // Копируем вопросы в roomPack
+ if (sourcePack && sourcePack.questions) {
+ await this.roomPackService.updateQuestions(
+ roomId,
+ Array.isArray(sourcePack.questions) ? sourcePack.questions as any[] : []
+ );
+ }
+
+ // Получаем обновленный roomPack для установки currentQuestionId
+ const roomPack = await this.roomPackService.findByRoomId(roomId);
+ const questions = roomPack?.questions as any[] || [];
+ const firstQuestionId = questions.length > 0 && questions[0].id ? questions[0].id : null;
+
+ // Обновляем комнату с новым паком и первым вопросом
+ const room = await this.prisma.room.update({
where: { id: roomId },
data: {
questionPackId,
currentQuestionIndex: 0,
+ currentQuestionId: firstQuestionId,
revealedAnswers: {},
},
include: {
@@ -127,8 +170,14 @@ export class RoomsService {
include: { user: true },
},
questionPack: true,
+ roomPack: true,
},
});
+
+ // Отправляем обновление через WebSocket
+ this.roomEventsService.emitRoomUpdate(room.code, room);
+
+ return room;
}
async updateCustomQuestions(roomId: string, questions: any) {
diff --git a/backend/src/themes/themes.controller.ts b/backend/src/themes/themes.controller.ts
new file mode 100644
index 0000000..f679330
--- /dev/null
+++ b/backend/src/themes/themes.controller.ts
@@ -0,0 +1,51 @@
+import {
+ Controller,
+ Get,
+ Param,
+ NotFoundException,
+ ForbiddenException,
+} from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+
+@Controller('api/themes')
+export class ThemesController {
+ constructor(private prisma: PrismaService) {}
+
+ @Get()
+ async getPublicThemes() {
+ return this.prisma.theme.findMany({
+ where: { isPublic: true },
+ select: {
+ id: true,
+ name: true,
+ colors: true,
+ settings: true,
+ },
+ orderBy: { name: 'asc' },
+ });
+ }
+
+ @Get(':id')
+ async getTheme(@Param('id') id: string) {
+ const theme = await this.prisma.theme.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ name: true,
+ isPublic: true,
+ colors: true,
+ settings: true,
+ },
+ });
+
+ if (!theme) {
+ throw new NotFoundException('Theme not found');
+ }
+
+ if (!theme.isPublic) {
+ throw new ForbiddenException('Theme is private');
+ }
+
+ return theme;
+ }
+}
diff --git a/backend/src/themes/themes.module.ts b/backend/src/themes/themes.module.ts
new file mode 100644
index 0000000..cca3e00
--- /dev/null
+++ b/backend/src/themes/themes.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { ThemesController } from './themes.controller';
+import { PrismaModule } from '../prisma/prisma.module';
+
+@Module({
+ imports: [PrismaModule],
+ controllers: [ThemesController],
+})
+export class ThemesModule {}
diff --git a/src/App.css b/src/App.css
index ce9c37d..f663771 100644
--- a/src/App.css
+++ b/src/App.css
@@ -110,7 +110,7 @@
}
.title-number {
- color: #ffd700;
+ color: var(--accent-primary);
display: inline-block;
margin: 0 10px;
}
@@ -133,7 +133,7 @@
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 15px;
padding: clamp(4px, 0.8vh, 6px) clamp(10px, 1.5vw, 15px);
- color: #ffd700;
+ color: var(--accent-primary);
font-size: clamp(0.8rem, 1.8vw, 1rem);
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@@ -168,7 +168,7 @@
.app-subtitle {
text-align: center;
- color: #fff;
+ color: var(--text-primary);
font-size: clamp(1rem, 2vw, 1.5rem);
margin-bottom: clamp(5px, 1vh, 15px);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
diff --git a/src/components/Answer.css b/src/components/Answer.css
index e368d3b..ecaccda 100644
--- a/src/components/Answer.css
+++ b/src/components/Answer.css
@@ -120,7 +120,7 @@
.answer-text {
font-size: clamp(0.9rem, 1.8vw, 1.4rem);
- color: #fff;
+ color: var(--text-primary);
font-weight: bold;
text-align: center;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
diff --git a/src/components/Game.css b/src/components/Game.css
index 79e54a8..2b1d026 100644
--- a/src/components/Game.css
+++ b/src/components/Game.css
@@ -52,21 +52,21 @@
.game-over-title {
font-size: clamp(1.5rem, 4vw, 3rem);
- color: #ffd700;
+ color: var(--accent-primary);
margin-bottom: clamp(10px, 2vh, 20px);
text-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
}
.game-over-score {
font-size: 3.5rem;
- color: #fff;
+ color: var(--text-primary);
margin-bottom: 40px;
font-weight: bold;
}
.restart-button {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
- color: white;
+ color: var(--text-primary);
border: none;
padding: clamp(10px, 2vh, 15px) clamp(30px, 5vw, 50px);
font-size: clamp(1rem, 2vw, 1.5rem);
@@ -101,7 +101,7 @@
}
.no-players-message p {
- color: #fff;
+ color: var(--text-primary);
font-size: clamp(1rem, 2vw, 1.3rem);
margin: 0;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
@@ -115,7 +115,7 @@
}
.final-scores-title {
- color: #fff;
+ color: var(--text-primary);
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
margin-bottom: clamp(10px, 2vh, 15px);
text-align: center;
@@ -140,13 +140,13 @@
}
.final-score-name {
- color: #fff;
+ color: var(--text-primary);
font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: 500;
}
.final-score-value {
- color: #ffd700;
+ color: var(--accent-primary);
font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@@ -154,7 +154,7 @@
.final-score-winner .final-score-name,
.final-score-winner .final-score-value {
- color: #ffd700;
+ color: var(--accent-primary);
text-shadow: 0 0 15px rgba(255, 215, 0, 0.8);
}
diff --git a/src/components/GameManagementModal.css b/src/components/GameManagementModal.css
index 7da3199..c408106 100644
--- a/src/components/GameManagementModal.css
+++ b/src/components/GameManagementModal.css
@@ -232,7 +232,7 @@
.player-edit-save {
background: var(--accent-success, #4ecdc4);
- color: white;
+ color: var(--text-primary);
}
.player-edit-save:hover {
@@ -341,7 +341,7 @@
.start-button {
background: var(--accent-success, #4ecdc4);
border-color: var(--accent-success, #4ecdc4);
- color: white;
+ color: var(--text-primary);
width: 100%;
font-size: 1.1rem;
}
@@ -349,7 +349,7 @@
.end-button {
background: var(--accent-secondary, #ff6b6b);
border-color: var(--accent-secondary, #ff6b6b);
- color: white;
+ color: var(--text-primary);
}
.toggle-all-button {
@@ -383,7 +383,7 @@
.answer-button.revealed {
background: var(--accent-success, #4ecdc4);
border-color: var(--accent-success, #4ecdc4);
- color: white;
+ color: var(--text-primary);
}
.answer-button.hidden {
@@ -417,7 +417,7 @@
}
.answer-button.revealed .answer-pts {
- color: white;
+ color: var(--text-primary);
}
/* Scoring tab */
@@ -462,13 +462,13 @@
.points-button {
background: var(--accent-success, #4ecdc4);
border-color: var(--accent-success, #4ecdc4);
- color: white;
+ color: var(--text-primary);
}
.penalty-button {
background: var(--accent-secondary, #ff6b6b);
border-color: var(--accent-secondary, #ff6b6b);
- color: white;
+ color: var(--text-primary);
}
.custom-points {
@@ -525,7 +525,8 @@
.questions-tab-content .questions-modal-export-button,
.questions-tab-content .questions-modal-import-button,
-.questions-tab-content .questions-modal-pack-import-button {
+.questions-tab-content .questions-modal-pack-import-button,
+.questions-tab-content .questions-modal-template-button {
padding: 0.5rem 1rem;
background: var(--bg-card, #1a1a1a);
border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
@@ -539,7 +540,8 @@
.questions-tab-content .questions-modal-export-button:hover,
.questions-tab-content .questions-modal-import-button:hover,
-.questions-tab-content .questions-modal-pack-import-button:hover {
+.questions-tab-content .questions-modal-pack-import-button:hover,
+.questions-tab-content .questions-modal-template-button:hover {
border-color: var(--accent-primary, #ffd700);
transform: translateY(-2px);
}
@@ -586,7 +588,7 @@
background: var(--accent-success, #4ecdc4);
border: none;
border-radius: var(--border-radius-sm, 8px);
- color: white;
+ color: var(--text-primary);
font-weight: 600;
cursor: pointer;
}
@@ -715,7 +717,7 @@
.questions-tab-content .questions-modal-save-button {
background: var(--accent-success, #4ecdc4);
- color: white;
+ color: var(--text-primary);
border-color: var(--accent-success, #4ecdc4);
}
diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx
index b253f4e..a39ddc5 100644
--- a/src/components/GameManagementModal.jsx
+++ b/src/components/GameManagementModal.jsx
@@ -257,6 +257,49 @@ const GameManagementModal = ({
}
}
+ const handleDownloadTemplate = () => {
+ const template = [
+ {
+ text: 'Назовите самый популярный вид спорта в мире',
+ answers: [
+ { text: 'Футбол', points: 100 },
+ { text: 'Баскетбол', points: 80 },
+ { text: 'Теннис', points: 60 },
+ { text: 'Хоккей', points: 40 },
+ { text: 'Волейбол', points: 20 },
+ { text: 'Бокс', points: 10 },
+ ]
+ },
+ {
+ text: 'Что люди обычно берут с собой на пляж?',
+ answers: [
+ { text: 'Полотенце', points: 100 },
+ { text: 'Крем от солнца', points: 80 },
+ { text: 'Очки', points: 60 },
+ { text: 'Зонт', points: 40 },
+ { text: 'Книга', points: 20 },
+ { text: 'Еда', points: 10 },
+ ]
+ }
+ ]
+
+ try {
+ const jsonString = JSON.stringify(template, null, 2)
+ const blob = new Blob([jsonString], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = 'template_questions.json'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+ setJsonError('')
+ } catch (error) {
+ setJsonError('Ошибка при скачивании шаблона: ' + error.message)
+ }
+ }
+
const handleImportJson = () => {
const input = document.createElement('input')
input.type = 'file'
@@ -275,21 +318,31 @@ const GameManagementModal = ({
return
}
+ // Валидация - id опционален (будет сгенерирован автоматически)
const isValid = jsonContent.every(q =>
- q.id &&
typeof q.text === 'string' &&
Array.isArray(q.answers) &&
q.answers.every(a => a.text && typeof a.points === 'number')
)
if (!isValid) {
- setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: id, text, answers')
+ setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: text, answers')
return
}
- onUpdateQuestions(jsonContent)
+ // Добавляем id если его нет
+ const questionsWithIds = jsonContent.map((q, idx) => ({
+ ...q,
+ id: q.id || Date.now() + Math.random() + idx,
+ answers: q.answers.map((a, aidx) => ({
+ ...a,
+ id: a.id || `answer-${Date.now()}-${idx}-${aidx}`
+ }))
+ }))
+
+ onUpdateQuestions(questionsWithIds)
setJsonError('')
- alert(`Успешно импортировано ${jsonContent.length} вопросов`)
+ alert(`Успешно импортировано ${questionsWithIds.length} вопросов`)
} catch (error) {
setJsonError('Ошибка при импорте: ' + error.message)
}
@@ -717,10 +770,10 @@ const GameManagementModal = ({
+
{availablePacks.length > 0 && (