admin
This commit is contained in:
parent
73315bcf45
commit
96577926c8
47 changed files with 3640 additions and 190 deletions
|
|
@ -4,6 +4,8 @@ import LoginPage from '@/pages/LoginPage'
|
||||||
import DashboardPage from '@/pages/DashboardPage'
|
import DashboardPage from '@/pages/DashboardPage'
|
||||||
import PacksPage from '@/pages/PacksPage'
|
import PacksPage from '@/pages/PacksPage'
|
||||||
import UsersPage from '@/pages/UsersPage'
|
import UsersPage from '@/pages/UsersPage'
|
||||||
|
import ThemesPage from '@/pages/ThemesPage'
|
||||||
|
import RoomsPage from '@/pages/RoomsPage'
|
||||||
import Layout from '@/components/layout/Layout'
|
import Layout from '@/components/layout/Layout'
|
||||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||||
|
|
||||||
|
|
@ -27,6 +29,8 @@ function App() {
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/packs" element={<PacksPage />} />
|
<Route path="/packs" element={<PacksPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/themes" element={<ThemesPage />} />
|
||||||
|
<Route path="/rooms" element={<RoomsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
192
admin/src/api/rooms.ts
Normal file
192
admin/src/api/rooms.ts
Normal file
|
|
@ -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<RoomsResponse> => {
|
||||||
|
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<RoomDto> => {
|
||||||
|
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<RoomDto> => {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
238
admin/src/api/themes.ts
Normal file
238
admin/src/api/themes.ts
Normal file
|
|
@ -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<PaginatedThemesResponse> => {
|
||||||
|
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<Theme> => {
|
||||||
|
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<Theme> => {
|
||||||
|
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<Theme> => {
|
||||||
|
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',
|
||||||
|
}
|
||||||
483
admin/src/components/CreateAdminRoomDialog.tsx
Normal file
483
admin/src/components/CreateAdminRoomDialog.tsx
Normal file
|
|
@ -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<CreateAdminRoomDto>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Admin Room</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new game room with custom settings
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-6 py-4">
|
||||||
|
{/* Host Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hostId">Host *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.hostId}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, hostId: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="hostId">
|
||||||
|
<SelectValue placeholder="Select a host" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{usersData?.items.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name || 'No name'} ({user.email || user.telegramId || 'No contact'})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Host Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hostName">Host Name in Game</Label>
|
||||||
|
<Input
|
||||||
|
id="hostName"
|
||||||
|
value={formData.hostName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, hostName: e.target.value })}
|
||||||
|
placeholder="Ведущий"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Code */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="customCode">Custom Room Code (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="customCode"
|
||||||
|
value={formData.customCode}
|
||||||
|
onChange={(e) => setFormData({ ...formData, customCode: e.target.value })}
|
||||||
|
placeholder="Leave empty for random code"
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Leave empty to generate a random 6-character code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Period */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Active Period (optional)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Set when the room should be available
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="activeFrom">Available From</Label>
|
||||||
|
<Input
|
||||||
|
id="activeFrom"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.activeFrom ? formatDateTimeLocal(formData.activeFrom) : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
activeFrom: parseDateTimeLocal(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="activeTo">Available To</Label>
|
||||||
|
<Input
|
||||||
|
id="activeTo"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.activeTo ? formatDateTimeLocal(formData.activeTo) : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
activeTo: parseDateTimeLocal(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Theme Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="themeId">Theme</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.themeId || ''}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, themeId: value || undefined })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="themeId">
|
||||||
|
<SelectValue placeholder="Default theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Default theme</SelectItem>
|
||||||
|
{themesData?.themes.map((theme) => (
|
||||||
|
<SelectItem key={theme.id} value={theme.id}>
|
||||||
|
{theme.name} {theme.isPublic ? '(Public)' : '(Private)'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question Pack */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="questionPackId">Question Pack</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.questionPackId || ''}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, questionPackId: value || undefined })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="questionPackId">
|
||||||
|
<SelectValue placeholder="No questions" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">No questions</SelectItem>
|
||||||
|
{packsData?.items.map((pack) => (
|
||||||
|
<SelectItem key={pack.id} value={pack.id}>
|
||||||
|
{pack.title} ({pack.cards} questions)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UI Controls */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">UI Controls</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control which features are available to players and host
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="allowThemeChange"
|
||||||
|
checked={formData.uiControls?.allowThemeChange}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
uiControls: {
|
||||||
|
...formData.uiControls,
|
||||||
|
allowThemeChange: checked as boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="allowThemeChange" className="cursor-pointer">
|
||||||
|
Allow theme change
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="allowPackChange"
|
||||||
|
checked={formData.uiControls?.allowPackChange}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
uiControls: {
|
||||||
|
...formData.uiControls,
|
||||||
|
allowPackChange: checked as boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="allowPackChange" className="cursor-pointer">
|
||||||
|
Allow pack change
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="allowNameChange"
|
||||||
|
checked={formData.uiControls?.allowNameChange}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
uiControls: {
|
||||||
|
...formData.uiControls,
|
||||||
|
allowNameChange: checked as boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="allowNameChange" className="cursor-pointer">
|
||||||
|
Allow name change
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="allowScoreEdit"
|
||||||
|
checked={formData.uiControls?.allowScoreEdit}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
uiControls: {
|
||||||
|
...formData.uiControls,
|
||||||
|
allowScoreEdit: checked as boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="allowScoreEdit" className="cursor-pointer">
|
||||||
|
Allow score editing
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Game Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Game Settings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxPlayers">Max Players</Label>
|
||||||
|
<Input
|
||||||
|
id="maxPlayers"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={formData.settings?.maxPlayers || 10}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
settings: {
|
||||||
|
...formData.settings,
|
||||||
|
maxPlayers: parseInt(e.target.value) || 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="allowSpectators"
|
||||||
|
checked={formData.settings?.allowSpectators}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
settings: {
|
||||||
|
...formData.settings,
|
||||||
|
allowSpectators: checked as boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="allowSpectators" className="cursor-pointer">
|
||||||
|
Allow spectators
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="timerEnabled"
|
||||||
|
checked={formData.settings?.timerEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
settings: {
|
||||||
|
...formData.settings,
|
||||||
|
timerEnabled: checked as boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="timerEnabled" className="cursor-pointer">
|
||||||
|
Enable timer
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{formData.settings?.timerEnabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timerDuration">Timer Duration (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="timerDuration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="300"
|
||||||
|
value={formData.settings?.timerDuration || 30}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
settings: {
|
||||||
|
...formData.settings,
|
||||||
|
timerDuration: parseInt(e.target.value) || 30,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? 'Creating...' : 'Create Room'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
304
admin/src/components/PackImportDialog.tsx
Normal file
304
admin/src/components/PackImportDialog.tsx
Normal file
|
|
@ -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<HTMLInputElement>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [jsonContent, setJsonContent] = useState('')
|
||||||
|
const [packInfo, setPackInfo] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
isPublic: true,
|
||||||
|
})
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null)
|
||||||
|
const [questionsCount, setQuestionsCount] = useState<number | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Pack from JSON</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a JSON file or paste JSON content to create a new pack with questions.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Upload JSON File</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Select File
|
||||||
|
</Button>
|
||||||
|
{fileInputRef.current?.files?.[0] && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
<FileJson className="h-4 w-4 inline mr-1" />
|
||||||
|
{fileInputRef.current.files[0].name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Content */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="jsonContent">Or Paste JSON Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id="jsonContent"
|
||||||
|
value={jsonContent}
|
||||||
|
onChange={(e) => handleJsonChange(e.target.value)}
|
||||||
|
placeholder='[{"question": "...", "answers": [{"text": "...", "points": 100}]}]'
|
||||||
|
rows={8}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{parseError && (
|
||||||
|
<p className="text-sm text-red-500">{parseError}</p>
|
||||||
|
)}
|
||||||
|
{questionsCount !== null && !parseError && (
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
Found {questionsCount} valid question{questionsCount !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-2" />
|
||||||
|
|
||||||
|
{/* Pack Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Pack Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={packInfo.name}
|
||||||
|
onChange={(e) => setPackInfo((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="Enter pack name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={packInfo.description}
|
||||||
|
onChange={(e) => setPackInfo((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="Enter pack description"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Category *</Label>
|
||||||
|
<Input
|
||||||
|
id="category"
|
||||||
|
value={packInfo.category}
|
||||||
|
onChange={(e) => setPackInfo((prev) => ({ ...prev, category: e.target.value }))}
|
||||||
|
placeholder="Enter category (e.g., general, family, etc.)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isPublic"
|
||||||
|
checked={packInfo.isPublic}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setPackInfo((prev) => ({ ...prev, isPublic: checked as boolean }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isPublic">Public</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={isLoading || !!parseError || questionsCount === null}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Importing...' : 'Import Pack'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
413
admin/src/components/ThemeEditorDialog.tsx
Normal file
413
admin/src/components/ThemeEditorDialog.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isGradient && (
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value.startsWith('#') ? value : '#ffffff'}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded border cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={label}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeEditorDialog({
|
||||||
|
open,
|
||||||
|
theme,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
isSaving = false,
|
||||||
|
}: ThemeEditorDialogProps) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [isPublic, setIsPublic] = useState(false)
|
||||||
|
const [colors, setColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS)
|
||||||
|
const [settings, setSettings] = useState<ThemeSettings>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{theme ? 'Edit Theme' : 'Create Theme'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{theme ? 'Update the theme configuration' : 'Create a new theme for the game'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Theme Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="My Custom Theme"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 pt-8">
|
||||||
|
<Checkbox
|
||||||
|
id="isPublic"
|
||||||
|
checked={isPublic}
|
||||||
|
onCheckedChange={(checked) => setIsPublic(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isPublic">Public theme (available to all users)</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs for Colors and Settings */}
|
||||||
|
<Tabs defaultValue="colors">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="colors">Colors</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
|
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="colors" className="space-y-4 pt-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<ColorField
|
||||||
|
label="Background Primary"
|
||||||
|
value={colors.bgPrimary}
|
||||||
|
onChange={(v) => updateColor('bgPrimary', v)}
|
||||||
|
description="Main background (gradient or solid)"
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Background Overlay"
|
||||||
|
value={colors.bgOverlay}
|
||||||
|
onChange={(v) => updateColor('bgOverlay', v)}
|
||||||
|
description="Overlay color (rgba recommended)"
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Card Background"
|
||||||
|
value={colors.bgCard}
|
||||||
|
onChange={(v) => updateColor('bgCard', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Card Hover"
|
||||||
|
value={colors.bgCardHover}
|
||||||
|
onChange={(v) => updateColor('bgCardHover', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Text Primary"
|
||||||
|
value={colors.textPrimary}
|
||||||
|
onChange={(v) => updateColor('textPrimary', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Text Secondary"
|
||||||
|
value={colors.textSecondary}
|
||||||
|
onChange={(v) => updateColor('textSecondary', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Text Glow"
|
||||||
|
value={colors.textGlow}
|
||||||
|
onChange={(v) => updateColor('textGlow', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Accent Primary"
|
||||||
|
value={colors.accentPrimary}
|
||||||
|
onChange={(v) => updateColor('accentPrimary', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Accent Secondary"
|
||||||
|
value={colors.accentSecondary}
|
||||||
|
onChange={(v) => updateColor('accentSecondary', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Accent Success"
|
||||||
|
value={colors.accentSuccess}
|
||||||
|
onChange={(v) => updateColor('accentSuccess', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Border Color"
|
||||||
|
value={colors.borderColor}
|
||||||
|
onChange={(v) => updateColor('borderColor', v)}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Border Glow"
|
||||||
|
value={colors.borderGlow}
|
||||||
|
onChange={(v) => updateColor('borderGlow', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="settings" className="space-y-4 pt-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Shadow Small</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.shadowSm}
|
||||||
|
onChange={(e) => updateSetting('shadowSm', e.target.value)}
|
||||||
|
placeholder="0 1px 2px rgba(0, 0, 0, 0.1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Shadow Medium</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.shadowMd}
|
||||||
|
onChange={(e) => updateSetting('shadowMd', e.target.value)}
|
||||||
|
placeholder="0 4px 6px rgba(0, 0, 0, 0.1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Shadow Large</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.shadowLg}
|
||||||
|
onChange={(e) => updateSetting('shadowLg', e.target.value)}
|
||||||
|
placeholder="0 10px 15px rgba(0, 0, 0, 0.2)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Blur Amount</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.blurAmount}
|
||||||
|
onChange={(e) => updateSetting('blurAmount', e.target.value)}
|
||||||
|
placeholder="10px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Border Radius Small</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.borderRadiusSm}
|
||||||
|
onChange={(e) => updateSetting('borderRadiusSm', e.target.value)}
|
||||||
|
placeholder="4px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Border Radius Medium</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.borderRadiusMd}
|
||||||
|
onChange={(e) => updateSetting('borderRadiusMd', e.target.value)}
|
||||||
|
placeholder="8px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Border Radius Large</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.borderRadiusLg}
|
||||||
|
onChange={(e) => updateSetting('borderRadiusLg', e.target.value)}
|
||||||
|
placeholder="12px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Animation Speed</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.animationSpeed}
|
||||||
|
onChange={(e) => updateSetting('animationSpeed', e.target.value)}
|
||||||
|
placeholder="0.3s"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="preview" className="pt-4">
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 min-h-[300px]"
|
||||||
|
style={{
|
||||||
|
background: colors.bgPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-4 mb-4"
|
||||||
|
style={{
|
||||||
|
background: colors.bgOverlay,
|
||||||
|
backdropFilter: `blur(${settings.blurAmount})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="text-xl font-bold mb-2"
|
||||||
|
style={{
|
||||||
|
color: colors.accentPrimary,
|
||||||
|
textShadow: `0 0 10px ${colors.textGlow}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Theme Preview
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: colors.textPrimary }}>
|
||||||
|
This is primary text
|
||||||
|
</p>
|
||||||
|
<p style={{ color: colors.textSecondary }}>
|
||||||
|
This is secondary text
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div
|
||||||
|
className="flex-1 p-4 rounded-lg transition-all cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: colors.bgCard,
|
||||||
|
borderRadius: settings.borderRadiusMd,
|
||||||
|
border: `1px solid ${colors.borderColor}`,
|
||||||
|
boxShadow: settings.shadowMd,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="font-bold"
|
||||||
|
style={{ color: colors.textPrimary }}
|
||||||
|
>
|
||||||
|
Card Example
|
||||||
|
</div>
|
||||||
|
<div style={{ color: colors.textSecondary }}>
|
||||||
|
Card content here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 p-4 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: colors.bgCardHover,
|
||||||
|
borderRadius: settings.borderRadiusMd,
|
||||||
|
border: `1px solid ${colors.borderGlow}`,
|
||||||
|
boxShadow: `0 0 20px ${colors.borderGlow}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="font-bold"
|
||||||
|
style={{ color: colors.accentPrimary }}
|
||||||
|
>
|
||||||
|
Active Card
|
||||||
|
</div>
|
||||||
|
<div style={{ color: colors.accentSecondary }}>
|
||||||
|
Hover state
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style={{
|
||||||
|
background: colors.accentPrimary,
|
||||||
|
color: colors.bgPrimary.includes('gradient') ? '#000' : colors.bgPrimary,
|
||||||
|
borderRadius: settings.borderRadiusSm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Primary Button
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style={{
|
||||||
|
background: colors.accentSuccess,
|
||||||
|
color: '#000',
|
||||||
|
borderRadius: settings.borderRadiusSm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Success Button
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||||
|
{isSaving ? 'Saving...' : theme ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,11 @@ import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
Users,
|
Users,
|
||||||
|
Palette,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X
|
X,
|
||||||
|
DoorOpen
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,8 @@ const navigation = [
|
||||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||||
{ name: 'Packs', href: '/packs', icon: Package },
|
{ name: 'Packs', href: '/packs', icon: Package },
|
||||||
{ name: 'Users', href: '/users', icon: Users },
|
{ name: 'Users', href: '/users', icon: Users },
|
||||||
|
{ name: 'Themes', href: '/themes', icon: Palette },
|
||||||
|
{ name: 'Rooms', href: '/rooms', icon: DoorOpen },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,10 @@ import {
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
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 { GameQuestionsManager } from '@/components/GameQuestionsManager'
|
||||||
import { GameQuestionEditorDialog } from '@/components/GameQuestionEditorDialog'
|
import { GameQuestionEditorDialog } from '@/components/GameQuestionEditorDialog'
|
||||||
|
import { PackImportDialog } from '@/components/PackImportDialog'
|
||||||
|
|
||||||
export default function PacksPage() {
|
export default function PacksPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -55,6 +56,9 @@ export default function PacksPage() {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
const [packToDelete, setPackToDelete] = useState<CardPackPreviewDto | null>(null)
|
const [packToDelete, setPackToDelete] = useState<CardPackPreviewDto | null>(null)
|
||||||
|
|
||||||
|
// Import dialog state
|
||||||
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
|
||||||
|
|
||||||
// Question editor state
|
// Question editor state
|
||||||
const [isQuestionEditorOpen, setIsQuestionEditorOpen] = useState(false)
|
const [isQuestionEditorOpen, setIsQuestionEditorOpen] = useState(false)
|
||||||
const [editingQuestion, setEditingQuestion] = useState<{
|
const [editingQuestion, setEditingQuestion] = useState<{
|
||||||
|
|
@ -278,6 +282,41 @@ export default function PacksPage() {
|
||||||
setPage(1) // Reset to first page when searching
|
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) {
|
if (error) {
|
||||||
const errorMessage = isPacksApiError(error)
|
const errorMessage = isPacksApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
|
|
@ -317,11 +356,21 @@ export default function PacksPage() {
|
||||||
View, create, edit and delete card packs
|
View, create, edit and delete card packs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={handleDownloadTemplate}>
|
||||||
|
<FileJson className="mr-2 h-4 w-4" />
|
||||||
|
Template
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsImportDialogOpen(true)}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
<Button onClick={openCreateDialog}>
|
<Button onClick={openCreateDialog}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Pack
|
Add Pack
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -394,13 +443,23 @@ export default function PacksPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openEditDialog(pack)}
|
onClick={() => openEditDialog(pack)}
|
||||||
|
title="Edit pack"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleExportPack(pack)}
|
||||||
|
title="Export pack"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(pack)}
|
onClick={() => handleDelete(pack)}
|
||||||
|
title="Delete pack"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -565,6 +624,16 @@ export default function PacksPage() {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Import Dialog */}
|
||||||
|
<PackImportDialog
|
||||||
|
open={isImportDialogOpen}
|
||||||
|
onImport={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||||||
|
setIsImportDialogOpen(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setIsImportDialogOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
354
admin/src/pages/RoomsPage.tsx
Normal file
354
admin/src/pages/RoomsPage.tsx
Normal file
|
|
@ -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<RoomDto | null>(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 <Badge variant="secondary">Waiting</Badge>
|
||||||
|
case 'PLAYING':
|
||||||
|
return <Badge variant="default">Playing</Badge>
|
||||||
|
case 'FINISHED':
|
||||||
|
return <Badge variant="outline">Finished</Badge>
|
||||||
|
default:
|
||||||
|
return <Badge>{status}</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Rooms Management</h1>
|
||||||
|
<p className="text-muted-foreground">Error loading rooms</p>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-red-500">Failed to load rooms. Please try again later.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Rooms Management</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
View and manage game rooms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Room
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2 flex-1">
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by code, host name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === '' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('')}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'WAITING' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('WAITING')}
|
||||||
|
>
|
||||||
|
Waiting
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'PLAYING' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('PLAYING')}
|
||||||
|
>
|
||||||
|
Playing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'FINISHED' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('FINISHED')}
|
||||||
|
>
|
||||||
|
Finished
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rooms Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Rooms ({data?.total || 0})</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage game rooms and their settings
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">Loading rooms...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Host</TableHead>
|
||||||
|
<TableHead>Theme</TableHead>
|
||||||
|
<TableHead>Pack</TableHead>
|
||||||
|
<TableHead>Players</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Active Period</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRooms.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||||
|
No rooms found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredRooms.map((room) => (
|
||||||
|
<TableRow key={room.id}>
|
||||||
|
<TableCell className="font-mono font-medium">
|
||||||
|
{room.code}
|
||||||
|
{room.isAdminRoom && (
|
||||||
|
<Badge variant="secondary" className="ml-2">Admin</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(room.status)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{room.host.name || 'No name'}</div>
|
||||||
|
{room.host.email && (
|
||||||
|
<div className="text-sm text-muted-foreground">{room.host.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{room.theme ? (
|
||||||
|
<Badge variant="outline">{room.theme.name}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Default</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{room.questionPack ? (
|
||||||
|
<span className="text-sm">{room.questionPack.name}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{room._count?.participants || 0} / {room.maxPlayers}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(room.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{room.activeFrom && room.activeTo ? (
|
||||||
|
<div>
|
||||||
|
<div>{new Date(room.activeFrom).toLocaleDateString()}</div>
|
||||||
|
<div className="text-xs">to {new Date(room.activeTo).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(`/join/${room.code}`, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(room)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} rooms
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= data.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Room Dialog */}
|
||||||
|
{createDialogOpen && (
|
||||||
|
<CreateAdminRoomDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'rooms'] })
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Room</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the room "{roomToDelete?.code}"? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
381
admin/src/pages/ThemesPage.tsx
Normal file
381
admin/src/pages/ThemesPage.tsx
Normal file
|
|
@ -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<ThemePreview | null>(null)
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
|
const [themeToDelete, setThemeToDelete] = useState<ThemePreview | null>(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<CreateThemeDto> }) =>
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Themes Management</h1>
|
||||||
|
<p className="text-muted-foreground">Error loading themes</p>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-red-500 font-medium">Failed to load themes</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['themes'] })}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Themes Management</h1>
|
||||||
|
<p className="text-muted-foreground">Create and manage game themes</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreateEditor}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Theme
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search themes..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showPrivate"
|
||||||
|
checked={showPrivate}
|
||||||
|
onCheckedChange={(checked) => setShowPrivate(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showPrivate">Show private themes</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Themes Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Themes ({data?.total || 0})</CardTitle>
|
||||||
|
<CardDescription>Manage theme colors and settings</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">Loading themes...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Preview</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Public</TableHead>
|
||||||
|
<TableHead>Creator</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(data?.themes || []).map((theme) => (
|
||||||
|
<TableRow key={theme.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className="w-16 h-10 rounded-md border"
|
||||||
|
style={{
|
||||||
|
background: theme.colors.bgPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center text-xs font-bold"
|
||||||
|
style={{
|
||||||
|
color: theme.colors.accentPrimary,
|
||||||
|
textShadow: `0 0 4px ${theme.colors.textGlow}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{theme.name}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
theme.isPublic
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{theme.isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{theme.creator?.name || 'Unknown'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(theme.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEditEditor(theme)}
|
||||||
|
title="Edit theme"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(theme)}
|
||||||
|
title="Delete theme"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {(page - 1) * limit + 1} to{' '}
|
||||||
|
{Math.min(page * limit, data.total)} of {data.total} themes
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= data.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Theme Editor Dialog */}
|
||||||
|
<ThemeEditorDialog
|
||||||
|
open={isEditorOpen}
|
||||||
|
theme={editingTheme}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={closeEditor}
|
||||||
|
isSaving={createMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Theme</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the theme "{themeToDelete?.name}"? This
|
||||||
|
action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ model Room {
|
||||||
status RoomStatus @default(WAITING)
|
status RoomStatus @default(WAITING)
|
||||||
hostId String
|
hostId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime?
|
||||||
|
|
||||||
// Настройки
|
// Настройки
|
||||||
maxPlayers Int @default(10)
|
maxPlayers Int @default(10)
|
||||||
|
|
@ -47,6 +47,14 @@ model Room {
|
||||||
autoAdvance Boolean @default(false)
|
autoAdvance Boolean @default(false)
|
||||||
voiceMode 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)
|
currentQuestionIndex Int @default(0)
|
||||||
currentQuestionId String? // UUID текущего вопроса
|
currentQuestionId String? // UUID текущего вопроса
|
||||||
|
|
@ -66,6 +74,7 @@ model Room {
|
||||||
questionPack QuestionPack? @relation(fields: [questionPackId], references: [id])
|
questionPack QuestionPack? @relation(fields: [questionPackId], references: [id])
|
||||||
roomPack RoomPack?
|
roomPack RoomPack?
|
||||||
gameHistory GameHistory?
|
gameHistory GameHistory?
|
||||||
|
theme Theme? @relation(fields: [themeId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RoomStatus {
|
enum RoomStatus {
|
||||||
|
|
@ -187,4 +196,5 @@ model Theme {
|
||||||
settings Json // { shadowSm, shadowMd, blurAmount, borderRadius, animationSpeed, etc. }
|
settings Json // { shadowSm, shadowMd, blurAmount, borderRadius, animationSpeed, etc. }
|
||||||
|
|
||||||
creator User @relation(fields: [createdBy], references: [id])
|
creator User @relation(fields: [createdBy], references: [id])
|
||||||
|
rooms Room[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { RoomPackModule } from '../room-pack/room-pack.module';
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
import { AdminAuthController } from './auth/admin-auth.controller';
|
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 { AdminGameHistoryController } from './game-history/admin-game-history.controller';
|
||||||
import { AdminGameHistoryService } from './game-history/admin-game-history.service';
|
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
|
// Guards
|
||||||
import { AdminAuthGuard } from './guards/admin-auth.guard';
|
import { AdminAuthGuard } from './guards/admin-auth.guard';
|
||||||
import { AdminGuard } from './guards/admin.guard';
|
import { AdminGuard } from './guards/admin.guard';
|
||||||
|
|
@ -34,6 +39,7 @@ import { AdminGuard } from './guards/admin.guard';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RoomPackModule,
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|
@ -54,6 +60,7 @@ import { AdminGuard } from './guards/admin.guard';
|
||||||
AdminPacksController,
|
AdminPacksController,
|
||||||
AdminAnalyticsController,
|
AdminAnalyticsController,
|
||||||
AdminGameHistoryController,
|
AdminGameHistoryController,
|
||||||
|
AdminThemesController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminAuthService,
|
AdminAuthService,
|
||||||
|
|
@ -62,6 +69,7 @@ import { AdminGuard } from './guards/admin.guard';
|
||||||
AdminPacksService,
|
AdminPacksService,
|
||||||
AdminAnalyticsService,
|
AdminAnalyticsService,
|
||||||
AdminGameHistoryService,
|
AdminGameHistoryService,
|
||||||
|
AdminThemesService,
|
||||||
AdminAuthGuard,
|
AdminAuthGuard,
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -29,31 +29,13 @@ export class AdminPacksController {
|
||||||
return this.adminPacksService.findAll(filters);
|
return this.adminPacksService.findAll(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
// Static routes must come BEFORE dynamic :id routes
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('export/template')
|
@Get('export/template')
|
||||||
getTemplate() {
|
getTemplate() {
|
||||||
return {
|
return {
|
||||||
templateVersion: '1.0',
|
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: [
|
questions: [
|
||||||
{
|
{
|
||||||
question: 'Your question here',
|
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')
|
@Post('import')
|
||||||
async importPack(@Body() importPackDto: ImportPackDto, @Request() req) {
|
async importPack(@Body() importPackDto: ImportPackDto, @Request() req) {
|
||||||
// Validate question structure
|
// Validate question structure
|
||||||
|
|
@ -99,4 +76,30 @@ export class AdminPacksController {
|
||||||
|
|
||||||
return this.adminPacksService.create(importPackDto, req.user.sub);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AdminRoomsService } from './admin-rooms.service';
|
import { AdminRoomsService } from './admin-rooms.service';
|
||||||
import { RoomFiltersDto } from './dto/room-filters.dto';
|
import { RoomFiltersDto } from './dto/room-filters.dto';
|
||||||
|
import { CreateAdminRoomDto } from './dto/create-admin-room.dto';
|
||||||
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
||||||
import { AdminGuard } from '../guards/admin.guard';
|
import { AdminGuard } from '../guards/admin.guard';
|
||||||
|
|
||||||
|
|
@ -26,6 +29,11 @@ export class AdminRoomsController {
|
||||||
return this.adminRoomsService.findOne(id);
|
return this.adminRoomsService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateAdminRoomDto) {
|
||||||
|
return this.adminRoomsService.createAdminRoom(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.adminRoomsService.remove(id);
|
return this.adminRoomsService.remove(id);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { RoomFiltersDto } from './dto/room-filters.dto';
|
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()
|
@Injectable()
|
||||||
export class AdminRoomsService {
|
export class AdminRoomsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private roomPackService: RoomPackService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(filters: RoomFiltersDto) {
|
async findAll(filters: RoomFiltersDto) {
|
||||||
const { status, dateFrom, dateTo, page = 1, limit = 10 } = filters;
|
const { status, dateFrom, dateTo, page = 1, limit = 10 } = filters;
|
||||||
|
|
@ -128,4 +136,92 @@ export class AdminRoomsService {
|
||||||
|
|
||||||
return { message: 'Room deleted successfully' };
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
backend/src/admin/rooms/dto/create-admin-room.dto.ts
Normal file
94
backend/src/admin/rooms/dto/create-admin-room.dto.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
49
backend/src/admin/themes/admin-themes.controller.ts
Normal file
49
backend/src/admin/themes/admin-themes.controller.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
backend/src/admin/themes/admin-themes.service.ts
Normal file
151
backend/src/admin/themes/admin-themes.service.ts
Normal file
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/src/admin/themes/dto/create-theme.dto.ts
Normal file
91
backend/src/admin/themes/dto/create-theme.dto.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
25
backend/src/admin/themes/dto/theme-filters.dto.ts
Normal file
25
backend/src/admin/themes/dto/theme-filters.dto.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
4
backend/src/admin/themes/dto/update-theme.dto.ts
Normal file
4
backend/src/admin/themes/dto/update-theme.dto.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateThemeDto } from './create-theme.dto';
|
||||||
|
|
||||||
|
export class UpdateThemeDto extends PartialType(CreateThemeDto) {}
|
||||||
|
|
@ -10,6 +10,7 @@ import { GameModule } from './game/game.module';
|
||||||
import { StatsModule } from './stats/stats.module';
|
import { StatsModule } from './stats/stats.module';
|
||||||
import { VoiceModule } from './voice/voice.module';
|
import { VoiceModule } from './voice/voice.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
|
import { ThemesModule } from './themes/themes.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -22,6 +23,7 @@ import { AdminModule } from './admin/admin.module';
|
||||||
StatsModule,
|
StatsModule,
|
||||||
VoiceModule,
|
VoiceModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
ThemesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.broadcastFullState(payload.roomCode);
|
await this.broadcastFullState(payload.roomCode);
|
||||||
|
|
||||||
|
// Явно отправить событие начала игры для перенаправления всех игроков
|
||||||
|
this.server.to(payload.roomCode).emit('gameStarted', {
|
||||||
|
roomId: payload.roomId,
|
||||||
|
roomCode: payload.roomCode,
|
||||||
|
status: 'PLAYING'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('playerAction')
|
@SubscribeMessage('playerAction')
|
||||||
|
|
@ -529,6 +536,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
return;
|
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 если нужно)
|
// Обновляем вопросы через service (который добавит UUID если нужно)
|
||||||
await this.roomsService.updateRoomPack(payload.roomId, payload.questions);
|
await this.roomsService.updateRoomPack(payload.roomId, payload.questions);
|
||||||
|
|
||||||
|
|
@ -589,6 +607,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
return;
|
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.roomPackService.importQuestions(payload.roomId, payload.sourcePackId, payload.questionIndices);
|
||||||
await this.broadcastFullState(payload.roomCode);
|
await this.broadcastFullState(payload.roomCode);
|
||||||
}
|
}
|
||||||
|
|
@ -623,6 +652,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
return;
|
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) {
|
if (!payload.newName || payload.newName.trim().length === 0) {
|
||||||
client.emit('error', { message: 'Name cannot be empty' });
|
client.emit('error', { message: 'Name cannot be empty' });
|
||||||
return;
|
return;
|
||||||
|
|
@ -655,6 +695,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
return;
|
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)) {
|
if (typeof payload.newScore !== 'number' || isNaN(payload.newScore)) {
|
||||||
client.emit('error', { message: 'Invalid score value' });
|
client.emit('error', { message: 'Invalid score value' });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ export class RoomsController {
|
||||||
constructor(private roomsService: RoomsService) {}
|
constructor(private roomsService: RoomsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async createRoom(@Body() dto: { hostId: string; questionPackId?: string; settings?: any }) {
|
async createRoom(@Body() dto: { hostId: string; questionPackId?: string; settings?: any; hostName?: string }) {
|
||||||
return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings);
|
return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings, dto.hostName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':code')
|
@Get(':code')
|
||||||
|
|
|
||||||
|
|
@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { customAlphabet } from 'nanoid';
|
import { customAlphabet } from 'nanoid';
|
||||||
import { RoomEventsService } from '../game/room-events.service';
|
import { RoomEventsService } from '../game/room-events.service';
|
||||||
|
|
@ -15,7 +15,7 @@ export class RoomsService {
|
||||||
private roomPackService: RoomPackService,
|
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 code = nanoid();
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
|
|
@ -24,6 +24,9 @@ export class RoomsService {
|
||||||
if ('questionPackId' in cleanSettings) {
|
if ('questionPackId' in cleanSettings) {
|
||||||
delete cleanSettings.questionPackId;
|
delete cleanSettings.questionPackId;
|
||||||
}
|
}
|
||||||
|
if ('hostName' in cleanSettings) {
|
||||||
|
delete cleanSettings.hostName;
|
||||||
|
}
|
||||||
|
|
||||||
const room = await this.prisma.room.create({
|
const room = await this.prisma.room.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -39,11 +42,14 @@ export class RoomsService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Используем переданное имя хоста или имя пользователя или дефолт "Ведущий"
|
||||||
|
const finalHostName = hostName?.trim() || room.host.name || 'Ведущий';
|
||||||
|
|
||||||
await this.prisma.participant.create({
|
await this.prisma.participant.create({
|
||||||
data: {
|
data: {
|
||||||
userId: hostId,
|
userId: hostId,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
name: room.host.name || 'Host',
|
name: finalHostName,
|
||||||
role: 'HOST',
|
role: 'HOST',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -56,7 +62,7 @@ export class RoomsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRoomByCode(code: string) {
|
async getRoomByCode(code: string) {
|
||||||
return this.prisma.room.findUnique({
|
const room = await this.prisma.room.findUnique({
|
||||||
where: { code },
|
where: { code },
|
||||||
include: {
|
include: {
|
||||||
host: true,
|
host: true,
|
||||||
|
|
@ -65,8 +71,24 @@ export class RoomsService {
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
roomPack: 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') {
|
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
|
||||||
|
|
@ -114,11 +136,32 @@ export class RoomsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateQuestionPack(roomId: string, questionPackId: string) {
|
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 },
|
where: { id: roomId },
|
||||||
data: {
|
data: {
|
||||||
questionPackId,
|
questionPackId,
|
||||||
currentQuestionIndex: 0,
|
currentQuestionIndex: 0,
|
||||||
|
currentQuestionId: firstQuestionId,
|
||||||
revealedAnswers: {},
|
revealedAnswers: {},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -127,8 +170,14 @@ export class RoomsService {
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
},
|
},
|
||||||
questionPack: true,
|
questionPack: true,
|
||||||
|
roomPack: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Отправляем обновление через WebSocket
|
||||||
|
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||||
|
|
||||||
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCustomQuestions(roomId: string, questions: any) {
|
async updateCustomQuestions(roomId: string, questions: any) {
|
||||||
|
|
|
||||||
51
backend/src/themes/themes.controller.ts
Normal file
51
backend/src/themes/themes.controller.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/themes/themes.module.ts
Normal file
9
backend/src/themes/themes.module.ts
Normal file
|
|
@ -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 {}
|
||||||
|
|
@ -110,7 +110,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-number {
|
.title-number {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
padding: clamp(4px, 0.8vh, 6px) clamp(10px, 1.5vw, 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-size: clamp(0.8rem, 1.8vw, 1rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -168,7 +168,7 @@
|
||||||
|
|
||||||
.app-subtitle {
|
.app-subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: clamp(1rem, 2vw, 1.5rem);
|
font-size: clamp(1rem, 2vw, 1.5rem);
|
||||||
margin-bottom: clamp(5px, 1vh, 15px);
|
margin-bottom: clamp(5px, 1vh, 15px);
|
||||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
|
|
||||||
.answer-text {
|
.answer-text {
|
||||||
font-size: clamp(0.9rem, 1.8vw, 1.4rem);
|
font-size: clamp(0.9rem, 1.8vw, 1.4rem);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||||
|
|
|
||||||
|
|
@ -52,21 +52,21 @@
|
||||||
|
|
||||||
.game-over-title {
|
.game-over-title {
|
||||||
font-size: clamp(1.5rem, 4vw, 3rem);
|
font-size: clamp(1.5rem, 4vw, 3rem);
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
margin-bottom: clamp(10px, 2vh, 20px);
|
margin-bottom: clamp(10px, 2vh, 20px);
|
||||||
text-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
|
text-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-over-score {
|
.game-over-score {
|
||||||
font-size: 3.5rem;
|
font-size: 3.5rem;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-button {
|
.restart-button {
|
||||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
padding: clamp(10px, 2vh, 15px) clamp(30px, 5vw, 50px);
|
padding: clamp(10px, 2vh, 15px) clamp(30px, 5vw, 50px);
|
||||||
font-size: clamp(1rem, 2vw, 1.5rem);
|
font-size: clamp(1rem, 2vw, 1.5rem);
|
||||||
|
|
@ -101,7 +101,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-players-message p {
|
.no-players-message p {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: clamp(1rem, 2vw, 1.3rem);
|
font-size: clamp(1rem, 2vw, 1.3rem);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.final-scores-title {
|
.final-scores-title {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
|
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
|
||||||
margin-bottom: clamp(10px, 2vh, 15px);
|
margin-bottom: clamp(10px, 2vh, 15px);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -140,13 +140,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.final-score-name {
|
.final-score-name {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: clamp(1rem, 2vw, 1.3rem);
|
font-size: clamp(1rem, 2vw, 1.3rem);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.final-score-value {
|
.final-score-value {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: clamp(1rem, 2vw, 1.3rem);
|
font-size: clamp(1rem, 2vw, 1.3rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
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-name,
|
||||||
.final-score-winner .final-score-value {
|
.final-score-winner .final-score-value {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
text-shadow: 0 0 15px rgba(255, 215, 0, 0.8);
|
text-shadow: 0 0 15px rgba(255, 215, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@
|
||||||
|
|
||||||
.player-edit-save {
|
.player-edit-save {
|
||||||
background: var(--accent-success, #4ecdc4);
|
background: var(--accent-success, #4ecdc4);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-edit-save:hover {
|
.player-edit-save:hover {
|
||||||
|
|
@ -341,7 +341,7 @@
|
||||||
.start-button {
|
.start-button {
|
||||||
background: var(--accent-success, #4ecdc4);
|
background: var(--accent-success, #4ecdc4);
|
||||||
border-color: var(--accent-success, #4ecdc4);
|
border-color: var(--accent-success, #4ecdc4);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -349,7 +349,7 @@
|
||||||
.end-button {
|
.end-button {
|
||||||
background: var(--accent-secondary, #ff6b6b);
|
background: var(--accent-secondary, #ff6b6b);
|
||||||
border-color: var(--accent-secondary, #ff6b6b);
|
border-color: var(--accent-secondary, #ff6b6b);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-all-button {
|
.toggle-all-button {
|
||||||
|
|
@ -383,7 +383,7 @@
|
||||||
.answer-button.revealed {
|
.answer-button.revealed {
|
||||||
background: var(--accent-success, #4ecdc4);
|
background: var(--accent-success, #4ecdc4);
|
||||||
border-color: var(--accent-success, #4ecdc4);
|
border-color: var(--accent-success, #4ecdc4);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-button.hidden {
|
.answer-button.hidden {
|
||||||
|
|
@ -417,7 +417,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-button.revealed .answer-pts {
|
.answer-button.revealed .answer-pts {
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scoring tab */
|
/* Scoring tab */
|
||||||
|
|
@ -462,13 +462,13 @@
|
||||||
.points-button {
|
.points-button {
|
||||||
background: var(--accent-success, #4ecdc4);
|
background: var(--accent-success, #4ecdc4);
|
||||||
border-color: var(--accent-success, #4ecdc4);
|
border-color: var(--accent-success, #4ecdc4);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.penalty-button {
|
.penalty-button {
|
||||||
background: var(--accent-secondary, #ff6b6b);
|
background: var(--accent-secondary, #ff6b6b);
|
||||||
border-color: var(--accent-secondary, #ff6b6b);
|
border-color: var(--accent-secondary, #ff6b6b);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-points {
|
.custom-points {
|
||||||
|
|
@ -525,7 +525,8 @@
|
||||||
|
|
||||||
.questions-tab-content .questions-modal-export-button,
|
.questions-tab-content .questions-modal-export-button,
|
||||||
.questions-tab-content .questions-modal-import-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;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--bg-card, #1a1a1a);
|
background: var(--bg-card, #1a1a1a);
|
||||||
border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
|
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-export-button:hover,
|
||||||
.questions-tab-content .questions-modal-import-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);
|
border-color: var(--accent-primary, #ffd700);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
@ -586,7 +588,7 @@
|
||||||
background: var(--accent-success, #4ecdc4);
|
background: var(--accent-success, #4ecdc4);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--border-radius-sm, 8px);
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -715,7 +717,7 @@
|
||||||
|
|
||||||
.questions-tab-content .questions-modal-save-button {
|
.questions-tab-content .questions-modal-save-button {
|
||||||
background: var(--accent-success, #4ecdc4);
|
background: var(--accent-success, #4ecdc4);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border-color: var(--accent-success, #4ecdc4);
|
border-color: var(--accent-success, #4ecdc4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 handleImportJson = () => {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
|
|
@ -275,21 +318,31 @@ const GameManagementModal = ({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Валидация - id опционален (будет сгенерирован автоматически)
|
||||||
const isValid = jsonContent.every(q =>
|
const isValid = jsonContent.every(q =>
|
||||||
q.id &&
|
|
||||||
typeof q.text === 'string' &&
|
typeof q.text === 'string' &&
|
||||||
Array.isArray(q.answers) &&
|
Array.isArray(q.answers) &&
|
||||||
q.answers.every(a => a.text && typeof a.points === 'number')
|
q.answers.every(a => a.text && typeof a.points === 'number')
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: id, text, answers')
|
setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: text, answers')
|
||||||
return
|
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('')
|
setJsonError('')
|
||||||
alert(`Успешно импортировано ${jsonContent.length} вопросов`)
|
alert(`Успешно импортировано ${questionsWithIds.length} вопросов`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setJsonError('Ошибка при импорте: ' + error.message)
|
setJsonError('Ошибка при импорте: ' + error.message)
|
||||||
}
|
}
|
||||||
|
|
@ -717,10 +770,10 @@ const GameManagementModal = ({
|
||||||
|
|
||||||
<div className="questions-modal-actions">
|
<div className="questions-modal-actions">
|
||||||
<button
|
<button
|
||||||
className="questions-modal-export-button"
|
className="questions-modal-template-button"
|
||||||
onClick={handleExportJson}
|
onClick={handleDownloadTemplate}
|
||||||
>
|
>
|
||||||
📥 Экспорт JSON
|
📋 Скачать шаблон
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="questions-modal-import-button"
|
className="questions-modal-import-button"
|
||||||
|
|
@ -728,6 +781,12 @@ const GameManagementModal = ({
|
||||||
>
|
>
|
||||||
📤 Импорт JSON
|
📤 Импорт JSON
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="questions-modal-export-button"
|
||||||
|
onClick={handleExportJson}
|
||||||
|
>
|
||||||
|
📥 Экспорт JSON
|
||||||
|
</button>
|
||||||
{availablePacks.length > 0 && (
|
{availablePacks.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="questions-modal-pack-import-button"
|
className="questions-modal-pack-import-button"
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
.admin-button-start {
|
.admin-button-start {
|
||||||
background: var(--accent-success);
|
background: var(--accent-success);
|
||||||
border-color: var(--accent-success);
|
border-color: var(--accent-success);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-button-start:hover:not(:disabled) {
|
.admin-button-start:hover:not(:disabled) {
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
.admin-button-end {
|
.admin-button-end {
|
||||||
background: var(--accent-secondary);
|
background: var(--accent-secondary);
|
||||||
border-color: var(--accent-secondary);
|
border-color: var(--accent-secondary);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-button-end:hover:not(:disabled) {
|
.admin-button-end:hover:not(:disabled) {
|
||||||
|
|
@ -174,7 +174,7 @@
|
||||||
.answer-control-button.revealed {
|
.answer-control-button.revealed {
|
||||||
background: var(--accent-success);
|
background: var(--accent-success);
|
||||||
border-color: var(--accent-success);
|
border-color: var(--accent-success);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-control-button.hidden {
|
.answer-control-button.hidden {
|
||||||
|
|
@ -212,7 +212,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-control-button.revealed .answer-points {
|
.answer-control-button.revealed .answer-points {
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scoring Controls */
|
/* Scoring Controls */
|
||||||
|
|
@ -265,7 +265,7 @@
|
||||||
.admin-button-success {
|
.admin-button-success {
|
||||||
background: var(--accent-success);
|
background: var(--accent-success);
|
||||||
border-color: var(--accent-success);
|
border-color: var(--accent-success);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-button-success:hover:not(:disabled) {
|
.admin-button-success:hover:not(:disabled) {
|
||||||
|
|
@ -275,7 +275,7 @@
|
||||||
.admin-button-danger {
|
.admin-button-danger {
|
||||||
background: var(--accent-secondary);
|
background: var(--accent-secondary);
|
||||||
border-color: var(--accent-secondary);
|
border-color: var(--accent-secondary);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-button-danger:hover:not(:disabled) {
|
.admin-button-danger:hover:not(:disabled) {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-input-modal-title {
|
.name-input-modal-title {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
|
|
||||||
.name-input-field:focus {
|
.name-input-field:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.3);
|
box-shadow: 0 0 15px rgba(255, 215, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
@ -137,13 +137,13 @@
|
||||||
|
|
||||||
.name-input-submit-button {
|
.name-input-submit-button {
|
||||||
background: rgba(255, 215, 0, 0.2);
|
background: rgba(255, 215, 0, 0.2);
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
border-color: rgba(255, 215, 0, 0.5);
|
border-color: rgba(255, 215, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-input-submit-button:hover:not(:disabled) {
|
.name-input-submit-button:hover:not(:disabled) {
|
||||||
background: rgba(255, 215, 0, 0.3);
|
background: rgba(255, 215, 0, 0.3);
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,20 +32,20 @@
|
||||||
|
|
||||||
.player-item.player-active {
|
.player-item.player-active {
|
||||||
background: rgba(255, 215, 0, 0.25);
|
background: rgba(255, 215, 0, 0.25);
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
|
box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-name {
|
.player-name {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-item.player-active .player-name {
|
.player-item.player-active .player-name {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-item.player-active .player-score {
|
.player-item.player-active .player-score {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.players-modal-title {
|
.players-modal-title {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.players-modal-input:focus {
|
.players-modal-input:focus {
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
.players-modal-add-button {
|
.players-modal-add-button {
|
||||||
padding: 15px 30px;
|
padding: 15px 30px;
|
||||||
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.players-modal-item-name {
|
.players-modal-item-name {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-modal-title {
|
.qr-modal-title {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-modal-code-value {
|
.qr-modal-code-value {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -83,14 +83,14 @@
|
||||||
padding: clamp(6px, 1vh, 10px) clamp(15px, 2vw, 25px);
|
padding: clamp(6px, 1vh, 10px) clamp(15px, 2vw, 25px);
|
||||||
margin-bottom: clamp(10px, 2vh, 15px);
|
margin-bottom: clamp(10px, 2vh, 15px);
|
||||||
font-size: clamp(1rem, 2vw, 1.4rem);
|
font-size: clamp(1rem, 2vw, 1.4rem);
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.question-text {
|
.question-text {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: clamp(1.2rem, 3vw, 2.5rem);
|
font-size: clamp(1.2rem, 3vw, 2.5rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-modal-title {
|
.questions-modal-title {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -122,7 +122,7 @@
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
@ -134,7 +134,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-modal-input:focus {
|
.questions-modal-input:focus {
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
border: 2px solid rgba(255, 215, 0, 0.2);
|
border: 2px solid rgba(255, 215, 0, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
@ -193,7 +193,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-modal-answer-input:focus {
|
.questions-modal-answer-input:focus {
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@
|
||||||
border: 2px solid rgba(255, 215, 0, 0.2);
|
border: 2px solid rgba(255, 215, 0, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
@ -211,7 +211,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-modal-points-input:focus {
|
.questions-modal-points-input:focus {
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,7 +247,7 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 15px 30px;
|
padding: 15px 30px;
|
||||||
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -268,7 +268,7 @@
|
||||||
.questions-modal-cancel-button {
|
.questions-modal-cancel-button {
|
||||||
padding: 15px 30px;
|
padding: 15px 30px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -287,7 +287,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-modal-list-title {
|
.questions-modal-list-title {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -323,7 +323,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-modal-item-text {
|
.questions-modal-item-text {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
|
@ -438,7 +438,7 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -461,7 +461,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-import-section h3 {
|
.pack-import-section h3 {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -474,7 +474,7 @@
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -482,14 +482,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-import-select:focus {
|
.pack-import-select:focus {
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-import-select option {
|
.pack-import-select option {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -504,14 +504,14 @@
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
border-bottom: 2px solid rgba(255, 215, 0, 0.2);
|
border-bottom: 2px solid rgba(255, 215, 0, 0.2);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-import-confirm-button {
|
.pack-import-confirm-button {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|
@ -570,7 +570,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-question-content strong {
|
.pack-question-content strong {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
|
@ -593,7 +593,7 @@
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
@ -604,7 +604,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-search-input:focus {
|
.pack-search-input:focus {
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-primary);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
@ -708,7 +708,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-question-viewer-header h4 {
|
.pack-question-viewer-header h4 {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
|
@ -744,7 +744,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-question-viewer-text {
|
.pack-question-viewer-text {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
|
@ -756,7 +756,7 @@
|
||||||
.pack-show-answers-button {
|
.pack-show-answers-button {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -795,7 +795,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-answer-text {
|
.pack-answer-text {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
@ -803,7 +803,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-answer-points {
|
.pack-answer-points {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,75 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const SNOWFLAKE_LIFETIME = 15000 // 15 seconds max lifetime
|
||||||
|
const TARGET_COUNT = 30 // Target number of snowflakes
|
||||||
|
const UPDATE_INTERVAL = 500 // Check every 500ms
|
||||||
|
|
||||||
|
function createSnowflake(id) {
|
||||||
|
return {
|
||||||
|
id: id || `snowflake-${Date.now()}-${Math.random()}`,
|
||||||
|
left: Math.random() * 100,
|
||||||
|
duration: Math.random() * 3 + 7, // 7-10s
|
||||||
|
delay: Math.random() * 2, // 0-2s delay for initial batch
|
||||||
|
size: Math.random() * 10 + 10, // 10-20px
|
||||||
|
opacity: Math.random() * 0.5 + 0.5, // 0.5-1
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const Snowflakes = () => {
|
const Snowflakes = () => {
|
||||||
const [snowflakes, setSnowflakes] = useState([])
|
const [snowflakes, setSnowflakes] = useState([])
|
||||||
|
|
||||||
|
// Initialize snowflakes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const createSnowflake = () => {
|
const initial = Array.from({ length: TARGET_COUNT }, (_, i) => createSnowflake(i))
|
||||||
const snowflake = {
|
setSnowflakes(initial)
|
||||||
id: Math.random(),
|
}, [])
|
||||||
left: Math.random() * 100,
|
|
||||||
animationDuration: Math.random() * 3 + 7,
|
|
||||||
delay: Math.random() * 5,
|
|
||||||
size: Math.random() * 10 + 10,
|
|
||||||
}
|
|
||||||
return snowflake
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialSnowflakes = Array.from({ length: 50 }, createSnowflake)
|
|
||||||
setSnowflakes(initialSnowflakes)
|
|
||||||
|
|
||||||
|
// Update cycle - remove old snowflakes and add new ones
|
||||||
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setSnowflakes((prev) => {
|
setSnowflakes((prev) => {
|
||||||
const newFlakes = prev.filter(
|
const now = Date.now()
|
||||||
(flake) => flake.id > Math.random() * 0.1
|
|
||||||
)
|
// Filter out snowflakes that have exceeded their lifetime
|
||||||
return [...newFlakes, createSnowflake()]
|
// Lifetime = delay + duration (in seconds, converted to ms)
|
||||||
|
const filtered = prev.filter((s) => {
|
||||||
|
const lifetime = (s.delay + s.duration) * 1000
|
||||||
|
return now - s.createdAt < lifetime + 1000 // +1s buffer
|
||||||
})
|
})
|
||||||
}, 3000)
|
|
||||||
|
// Add new snowflakes if below target
|
||||||
|
const newFlakes = [...filtered]
|
||||||
|
while (newFlakes.length < TARGET_COUNT) {
|
||||||
|
newFlakes.push(createSnowflake())
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFlakes
|
||||||
|
})
|
||||||
|
}, UPDATE_INTERVAL)
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="snowflakes-container">
|
||||||
{snowflakes.map((snowflake) => (
|
{snowflakes.map((snowflake) => (
|
||||||
<div
|
<div
|
||||||
key={snowflake.id}
|
key={snowflake.id}
|
||||||
className="snowflake"
|
className="snowflake"
|
||||||
style={{
|
style={{
|
||||||
left: `${snowflake.left}%`,
|
left: `${snowflake.left}%`,
|
||||||
animationDuration: `${snowflake.animationDuration}s`,
|
animationDuration: `${snowflake.duration}s`,
|
||||||
animationDelay: `${snowflake.delay}s`,
|
animationDelay: `${snowflake.delay}s`,
|
||||||
fontSize: `${snowflake.size}px`,
|
fontSize: `${snowflake.size}px`,
|
||||||
|
opacity: snowflake.opacity,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
❄
|
❄
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Snowflakes
|
export default Snowflakes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@
|
||||||
|
|
||||||
.voice-settings-effect-button.effect-correct:hover {
|
.voice-settings-effect-button.effect-correct:hover {
|
||||||
background: var(--accent-success);
|
background: var(--accent-success);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-settings-effect-button.effect-error {
|
.voice-settings-effect-button.effect-error {
|
||||||
|
|
@ -229,7 +229,7 @@
|
||||||
|
|
||||||
.voice-settings-effect-button.effect-error:hover {
|
.voice-settings-effect-button.effect-error:hover {
|
||||||
background: var(--accent-secondary);
|
background: var(--accent-secondary);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-settings-effect-button.effect-victory {
|
.voice-settings-effect-button.effect-victory {
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,44 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
const ThemeContext = createContext();
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
export const themes = {
|
// Built-in themes
|
||||||
|
export const BUILT_IN_THEMES = {
|
||||||
'new-year': {
|
'new-year': {
|
||||||
id: 'new-year',
|
id: 'new-year',
|
||||||
name: 'Новый год',
|
name: 'Новый год',
|
||||||
icon: '🎄',
|
icon: '🎄',
|
||||||
description: 'Праздничная новогодняя тема с золотым свечением',
|
description: 'Праздничная новогодняя тема с золотым свечением',
|
||||||
|
isBuiltIn: true,
|
||||||
},
|
},
|
||||||
family: {
|
family: {
|
||||||
id: 'family',
|
id: 'family',
|
||||||
name: 'Семейная',
|
name: 'Семейная',
|
||||||
icon: '🏠',
|
icon: '🏠',
|
||||||
description: 'Светлая и уютная тема для семейной игры',
|
description: 'Светлая и уютная тема для семейной игры',
|
||||||
|
isBuiltIn: true,
|
||||||
},
|
},
|
||||||
party: {
|
party: {
|
||||||
id: 'party',
|
id: 'party',
|
||||||
name: 'Вечеринка',
|
name: 'Вечеринка',
|
||||||
icon: '🎉',
|
icon: '🎉',
|
||||||
description: 'Яркая энергичная тема для шумных компаний',
|
description: 'Яркая энергичная тема для шумных компаний',
|
||||||
|
isBuiltIn: true,
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
id: 'dark',
|
id: 'dark',
|
||||||
name: 'Темная',
|
name: 'Темная',
|
||||||
icon: '🌙',
|
icon: '🌙',
|
||||||
description: 'Контрастная тема для ТВ и проектора',
|
description: 'Контрастная тема для ТВ и проектора',
|
||||||
|
isBuiltIn: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// For backwards compatibility
|
||||||
|
export const themes = BUILT_IN_THEMES;
|
||||||
|
|
||||||
|
// Helper to convert camelCase to kebab-case
|
||||||
|
const camelToKebab = (str) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const context = useContext(ThemeContext);
|
const context = useContext(ThemeContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|
@ -40,25 +51,105 @@ export const useTheme = () => {
|
||||||
export const ThemeProvider = ({ children }) => {
|
export const ThemeProvider = ({ children }) => {
|
||||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||||
const saved = localStorage.getItem('app-theme');
|
const saved = localStorage.getItem('app-theme');
|
||||||
return saved && themes[saved] ? saved : 'new-year';
|
return saved && BUILT_IN_THEMES[saved] ? saved : 'new-year';
|
||||||
});
|
});
|
||||||
|
const [customThemes, setCustomThemes] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load custom themes from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
const loadCustomThemes = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/themes');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCustomThemes(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load custom themes:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCustomThemes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme CSS variables
|
||||||
|
const applyTheme = (themeId) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Check if it's a built-in theme
|
||||||
|
if (BUILT_IN_THEMES[themeId]) {
|
||||||
|
root.setAttribute('data-theme', themeId);
|
||||||
|
// Reset any custom CSS variables
|
||||||
|
root.style.removeProperty('--text-primary');
|
||||||
|
root.style.removeProperty('--text-secondary');
|
||||||
|
root.style.removeProperty('--accent-primary');
|
||||||
|
root.style.removeProperty('--accent-secondary');
|
||||||
|
root.style.removeProperty('--bg-primary');
|
||||||
|
root.style.removeProperty('--bg-card');
|
||||||
|
root.style.removeProperty('--bg-card-hover');
|
||||||
|
root.style.removeProperty('--border-color');
|
||||||
|
root.style.removeProperty('--border-glow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a custom theme
|
||||||
|
const customTheme = customThemes.find((t) => t.id === themeId);
|
||||||
|
if (customTheme) {
|
||||||
|
root.removeAttribute('data-theme');
|
||||||
|
|
||||||
|
// Apply custom theme colors
|
||||||
|
if (customTheme.colors) {
|
||||||
|
Object.entries(customTheme.colors).forEach(([key, value]) => {
|
||||||
|
root.style.setProperty(`--${camelToKebab(key)}`, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom theme settings
|
||||||
|
if (customTheme.settings) {
|
||||||
|
Object.entries(customTheme.settings).forEach(([key, value]) => {
|
||||||
|
root.style.setProperty(`--${camelToKebab(key)}`, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply theme when currentTheme changes
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(currentTheme);
|
||||||
localStorage.setItem('app-theme', currentTheme);
|
localStorage.setItem('app-theme', currentTheme);
|
||||||
}, [currentTheme]);
|
}, [currentTheme, customThemes]);
|
||||||
|
|
||||||
const changeTheme = (themeId) => {
|
const changeTheme = (themeId) => {
|
||||||
if (themes[themeId]) {
|
// Check if it's a built-in or custom theme
|
||||||
|
if (BUILT_IN_THEMES[themeId] || customThemes.find((t) => t.id === themeId)) {
|
||||||
setCurrentTheme(themeId);
|
setCurrentTheme(themeId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Combine built-in and custom themes for display
|
||||||
|
const allThemes = {
|
||||||
|
...BUILT_IN_THEMES,
|
||||||
|
...customThemes.reduce((acc, theme) => {
|
||||||
|
acc[theme.id] = {
|
||||||
|
...theme,
|
||||||
|
icon: '🎨',
|
||||||
|
isBuiltIn: false,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentTheme,
|
currentTheme,
|
||||||
currentThemeData: themes[currentTheme],
|
currentThemeData: allThemes[currentTheme] || BUILT_IN_THEMES['new-year'],
|
||||||
themes,
|
themes: allThemes,
|
||||||
|
builtInThemes: BUILT_IN_THEMES,
|
||||||
|
customThemes,
|
||||||
changeTheme,
|
changeTheme,
|
||||||
|
loading,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,16 @@ export const useRoom = (roomCode, onGameStarted = null) => {
|
||||||
if (state.participants) {
|
if (state.participants) {
|
||||||
setParticipants(state.participants);
|
setParticipants(state.participants);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Также обновляем статус комнаты
|
||||||
|
if (state.status) {
|
||||||
|
setRoom(prevRoom => prevRoom ? { ...prevRoom, status: state.status } : prevRoom);
|
||||||
|
|
||||||
|
// Если игра началась, вызываем callback
|
||||||
|
if (state.status === 'PLAYING' && onGameStarted) {
|
||||||
|
onGameStarted(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socketService.on('roomUpdate', handleRoomUpdate);
|
socketService.on('roomUpdate', handleRoomUpdate);
|
||||||
|
|
@ -73,9 +83,9 @@ export const useRoom = (roomCode, onGameStarted = null) => {
|
||||||
};
|
};
|
||||||
}, [roomCode, onGameStarted, user?.id]);
|
}, [roomCode, onGameStarted, user?.id]);
|
||||||
|
|
||||||
const createRoom = useCallback(async (hostId, questionPackId, settings = {}) => {
|
const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => {
|
||||||
try {
|
try {
|
||||||
const response = await roomsApi.create(hostId, questionPackId, settings);
|
const response = await roomsApi.create(hostId, questionPackId, settings, hostName);
|
||||||
setRoom(response.data);
|
setRoom(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
166
src/index.css
166
src/index.css
|
|
@ -1,3 +1,70 @@
|
||||||
|
/* CSS Variables for theming */
|
||||||
|
:root {
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: rgba(255, 255, 255, 0.8);
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.5);
|
||||||
|
--accent-primary: #ffd700;
|
||||||
|
--accent-secondary: #ffed4e;
|
||||||
|
--accent-success: #4ade80;
|
||||||
|
--bg-primary: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%);
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.1);
|
||||||
|
--bg-card-hover: rgba(255, 255, 255, 0.2);
|
||||||
|
--border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
--border-glow: rgba(255, 215, 0, 0.3);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
--blur-amount: 10px;
|
||||||
|
--border-radius-sm: 4px;
|
||||||
|
--border-radius-md: 8px;
|
||||||
|
--border-radius-lg: 12px;
|
||||||
|
--animation-speed: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme variations */
|
||||||
|
[data-theme="new-year"] {
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: rgba(255, 255, 255, 0.8);
|
||||||
|
--accent-primary: #ffd700;
|
||||||
|
--accent-secondary: #ffed4e;
|
||||||
|
--bg-primary: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%);
|
||||||
|
--border-glow: rgba(255, 215, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="family"] {
|
||||||
|
--text-primary: #2d3748;
|
||||||
|
--text-secondary: rgba(45, 55, 72, 0.8);
|
||||||
|
--accent-primary: #4299e1;
|
||||||
|
--accent-secondary: #63b3ed;
|
||||||
|
--bg-primary: linear-gradient(135deg, #ebf4ff 0%, #c3dafe 100%);
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.7);
|
||||||
|
--bg-card-hover: rgba(255, 255, 255, 0.9);
|
||||||
|
--border-color: rgba(66, 153, 225, 0.3);
|
||||||
|
--border-glow: rgba(66, 153, 225, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="party"] {
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: rgba(255, 255, 255, 0.9);
|
||||||
|
--accent-primary: #f56565;
|
||||||
|
--accent-secondary: #fc8181;
|
||||||
|
--bg-primary: linear-gradient(135deg, #9f7aea 0%, #ed64a6 100%);
|
||||||
|
--border-glow: rgba(237, 100, 166, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: rgba(226, 232, 240, 0.8);
|
||||||
|
--accent-primary: #48bb78;
|
||||||
|
--accent-secondary: #68d391;
|
||||||
|
--bg-primary: linear-gradient(135deg, #000000 0%, #1a202c 100%);
|
||||||
|
--bg-card: rgba(45, 55, 72, 0.8);
|
||||||
|
--bg-card-hover: rgba(45, 55, 72, 0.95);
|
||||||
|
--border-color: rgba(74, 85, 104, 0.5);
|
||||||
|
--border-glow: rgba(72, 187, 120, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -10,7 +77,7 @@ body {
|
||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%);
|
background: var(--bg-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -27,15 +94,26 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Новогодние снежинки */
|
/* Новогодние снежинки */
|
||||||
|
.snowflakes-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes snow {
|
@keyframes snow {
|
||||||
0% {
|
0% {
|
||||||
transform: translateY(-20px) rotate(0deg);
|
transform: translateY(-20px) rotate(0deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
2% {
|
10% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
98% {
|
90% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
|
|
@ -45,18 +123,14 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.snowflake {
|
.snowflake {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0;
|
top: -20px;
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-family: Arial;
|
font-family: Arial;
|
||||||
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
|
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
|
||||||
animation-name: snow;
|
animation: snow linear forwards;
|
||||||
animation-timing-function: linear;
|
|
||||||
animation-iteration-count: 1;
|
|
||||||
animation-fill-mode: forwards;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +181,7 @@ body {
|
||||||
|
|
||||||
.welcome-text {
|
.welcome-text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +199,7 @@ body {
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -157,7 +231,7 @@ body {
|
||||||
|
|
||||||
.user-stats {
|
.user-stats {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
|
@ -174,7 +248,7 @@ body {
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +260,7 @@ body {
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
@ -222,6 +296,14 @@ body {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.button-group {
|
.button-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -235,7 +317,7 @@ body {
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -273,7 +355,7 @@ body {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group button.secondary:hover:not(:disabled) {
|
.button-group button.secondary:hover:not(:disabled) {
|
||||||
|
|
@ -302,7 +384,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-info p {
|
.pack-info p {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,7 +399,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-warning {
|
.pack-warning {
|
||||||
color: #ffd700;
|
color: var(--accent-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,7 +418,7 @@ body {
|
||||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
@ -355,7 +437,7 @@ body {
|
||||||
|
|
||||||
.pack-selector select option {
|
.pack-selector select option {
|
||||||
background: #1a1f3a;
|
background: #1a1f3a;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-selector button {
|
.pack-selector button {
|
||||||
|
|
@ -364,7 +446,7 @@ body {
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -392,7 +474,7 @@ body {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-selector button.secondary:hover:not(:disabled) {
|
.pack-selector button.secondary:hover:not(:disabled) {
|
||||||
|
|
@ -413,6 +495,34 @@ body {
|
||||||
.pack-selector button {
|
.pack-selector button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Фикс кнопок RoomPage на мобильных */
|
||||||
|
.button-group {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-container {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-container h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
|
|
@ -423,7 +533,7 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,7 +555,7 @@ body {
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -467,7 +577,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-info p {
|
.room-info p {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -516,7 +626,7 @@ body {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const CreateRoom = () => {
|
||||||
|
|
||||||
const [questionPacks, setQuestionPacks] = useState([]);
|
const [questionPacks, setQuestionPacks] = useState([]);
|
||||||
const [selectedPackId, setSelectedPackId] = useState('');
|
const [selectedPackId, setSelectedPackId] = useState('');
|
||||||
|
const [hostName, setHostName] = useState('');
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
maxPlayers: 10,
|
maxPlayers: 10,
|
||||||
allowSpectators: true,
|
allowSpectators: true,
|
||||||
|
|
@ -71,6 +72,7 @@ const CreateRoom = () => {
|
||||||
user.id,
|
user.id,
|
||||||
selectedPackId || undefined,
|
selectedPackId || undefined,
|
||||||
settings,
|
settings,
|
||||||
|
hostName.trim() || undefined,
|
||||||
);
|
);
|
||||||
navigate(`/room/${room.code}`);
|
navigate(`/room/${room.code}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -88,6 +90,18 @@ const CreateRoom = () => {
|
||||||
<div className="create-room-container">
|
<div className="create-room-container">
|
||||||
<h1>Создать комнату</h1>
|
<h1>Создать комнату</h1>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Ваше имя как ведущего:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hostName}
|
||||||
|
onChange={(e) => setHostName(e.target.value)}
|
||||||
|
placeholder="Ведущий"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<small className="form-hint">Оставьте пустым для использования «Ведущий»</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Выберите пак вопросов (можно добавить позже):</label>
|
<label>Выберите пак вопросов (можно добавить позже):</label>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
.manage-questions-button {
|
.manage-questions-button {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export const authApi = {
|
||||||
|
|
||||||
// Rooms endpoints
|
// Rooms endpoints
|
||||||
export const roomsApi = {
|
export const roomsApi = {
|
||||||
create: (hostId, questionPackId, settings) =>
|
create: (hostId, questionPackId, settings, hostName) =>
|
||||||
api.post('/rooms', { hostId, questionPackId, settings }),
|
api.post('/rooms', { hostId, questionPackId, settings, hostName }),
|
||||||
getByCode: (code) => api.get(`/rooms/${code}`),
|
getByCode: (code) => api.get(`/rooms/${code}`),
|
||||||
join: (roomId, userId, name, role) =>
|
join: (roomId, userId, name, role) =>
|
||||||
api.post(`/rooms/${roomId}/join`, { userId, name, role }),
|
api.post(`/rooms/${roomId}/join`, { userId, name, role }),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue