This commit is contained in:
Dmitry 2026-01-10 00:36:49 +03:00
parent 73315bcf45
commit 96577926c8
47 changed files with 3640 additions and 190 deletions

View file

@ -4,6 +4,8 @@ import LoginPage from '@/pages/LoginPage'
import DashboardPage from '@/pages/DashboardPage'
import PacksPage from '@/pages/PacksPage'
import UsersPage from '@/pages/UsersPage'
import ThemesPage from '@/pages/ThemesPage'
import RoomsPage from '@/pages/RoomsPage'
import Layout from '@/components/layout/Layout'
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
@ -27,6 +29,8 @@ function App() {
<Route path="/" element={<DashboardPage />} />
<Route path="/packs" element={<PacksPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/themes" element={<ThemesPage />} />
<Route path="/rooms" element={<RoomsPage />} />
</Routes>
</Layout>
) : (

192
admin/src/api/rooms.ts Normal file
View 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
View 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',
}

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

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

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

View file

@ -6,9 +6,11 @@ import {
LayoutDashboard,
Package,
Users,
Palette,
LogOut,
Menu,
X
X,
DoorOpen
} from 'lucide-react'
import { useState } from 'react'
@ -20,6 +22,8 @@ const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Packs', href: '/packs', icon: Package },
{ name: 'Users', href: '/users', icon: Users },
{ name: 'Themes', href: '/themes', icon: Palette },
{ name: 'Rooms', href: '/rooms', icon: DoorOpen },
]
export default function Layout({ children }: LayoutProps) {

View file

@ -41,9 +41,10 @@ import {
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, Download, FileJson } from 'lucide-react'
import { GameQuestionsManager } from '@/components/GameQuestionsManager'
import { GameQuestionEditorDialog } from '@/components/GameQuestionEditorDialog'
import { PackImportDialog } from '@/components/PackImportDialog'
export default function PacksPage() {
const queryClient = useQueryClient()
@ -55,6 +56,9 @@ export default function PacksPage() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [packToDelete, setPackToDelete] = useState<CardPackPreviewDto | null>(null)
// Import dialog state
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
// Question editor state
const [isQuestionEditorOpen, setIsQuestionEditorOpen] = useState(false)
const [editingQuestion, setEditingQuestion] = useState<{
@ -278,6 +282,41 @@ export default function PacksPage() {
setPage(1) // Reset to first page when searching
}
const downloadJson = (data: object, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handleDownloadTemplate = async () => {
try {
const template = await packsApi.getTemplate()
downloadJson(template, 'pack-template.json')
toast.success('Template downloaded')
} catch (error) {
const errorMessage = isPacksApiError(error) ? error.message : 'Failed to download template'
toast.error(errorMessage)
}
}
const handleExportPack = async (pack: CardPackPreviewDto) => {
try {
const exportedPack = await packsApi.exportPack(pack.id)
const safeName = pack.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()
downloadJson(exportedPack, `pack-${safeName}.json`)
toast.success('Pack exported successfully')
} catch (error) {
const errorMessage = isPacksApiError(error) ? error.message : 'Failed to export pack'
toast.error(errorMessage)
}
}
if (error) {
const errorMessage = isPacksApiError(error)
? error.message
@ -317,10 +356,20 @@ export default function PacksPage() {
View, create, edit and delete card packs
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Pack
</Button>
<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}>
<Plus className="mr-2 h-4 w-4" />
Add Pack
</Button>
</div>
</div>
{/* Search and Filters */}
@ -394,13 +443,23 @@ export default function PacksPage() {
variant="ghost"
size="sm"
onClick={() => openEditDialog(pack)}
title="Edit pack"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleExportPack(pack)}
title="Export pack"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(pack)}
title="Delete pack"
>
<Trash2 className="h-4 w-4" />
</Button>
@ -565,6 +624,16 @@ export default function PacksPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Import Dialog */}
<PackImportDialog
open={isImportDialogOpen}
onImport={() => {
queryClient.invalidateQueries({ queryKey: ['packs'] })
setIsImportDialogOpen(false)
}}
onClose={() => setIsImportDialogOpen(false)}
/>
</div>
)
}

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

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

View file

@ -36,7 +36,7 @@ model Room {
status RoomStatus @default(WAITING)
hostId String
createdAt DateTime @default(now())
expiresAt DateTime
expiresAt DateTime?
// Настройки
maxPlayers Int @default(10)
@ -47,6 +47,14 @@ model Room {
autoAdvance Boolean @default(false)
voiceMode Boolean @default(false) // Голосовой режим
// Админские комнаты
isAdminRoom Boolean @default(false)
customCode String? // Кастомный код вместо random
activeFrom DateTime? // Комната доступна с этого времени
activeTo DateTime? // Комната доступна до этого времени
themeId String? // FK на Theme
uiControls Json? // { allowThemeChange, allowPackChange, allowNameChange, allowScoreEdit }
// Состояние игры
currentQuestionIndex Int @default(0)
currentQuestionId String? // UUID текущего вопроса
@ -66,6 +74,7 @@ model Room {
questionPack QuestionPack? @relation(fields: [questionPackId], references: [id])
roomPack RoomPack?
gameHistory GameHistory?
theme Theme? @relation(fields: [themeId], references: [id])
}
enum RoomStatus {
@ -187,4 +196,5 @@ model Theme {
settings Json // { shadowSm, shadowMd, blurAmount, borderRadius, animationSpeed, etc. }
creator User @relation(fields: [createdBy], references: [id])
rooms Room[]
}

View file

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PrismaModule } from '../prisma/prisma.module';
import { RoomPackModule } from '../room-pack/room-pack.module';
// Auth
import { AdminAuthController } from './auth/admin-auth.controller';
@ -27,6 +28,10 @@ import { AdminAnalyticsService } from './analytics/admin-analytics.service';
import { AdminGameHistoryController } from './game-history/admin-game-history.controller';
import { AdminGameHistoryService } from './game-history/admin-game-history.service';
// Themes
import { AdminThemesController } from './themes/admin-themes.controller';
import { AdminThemesService } from './themes/admin-themes.service';
// Guards
import { AdminAuthGuard } from './guards/admin-auth.guard';
import { AdminGuard } from './guards/admin.guard';
@ -34,6 +39,7 @@ import { AdminGuard } from './guards/admin.guard';
@Module({
imports: [
PrismaModule,
RoomPackModule,
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
@ -54,6 +60,7 @@ import { AdminGuard } from './guards/admin.guard';
AdminPacksController,
AdminAnalyticsController,
AdminGameHistoryController,
AdminThemesController,
],
providers: [
AdminAuthService,
@ -62,6 +69,7 @@ import { AdminGuard } from './guards/admin.guard';
AdminPacksService,
AdminAnalyticsService,
AdminGameHistoryService,
AdminThemesService,
AdminAuthGuard,
AdminGuard,
],

View file

@ -29,31 +29,13 @@ export class AdminPacksController {
return this.adminPacksService.findAll(filters);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.adminPacksService.findOne(id);
}
@Post()
create(@Body() createPackDto: CreatePackDto, @Request() req) {
return this.adminPacksService.create(createPackDto, req.user.sub);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updatePackDto: UpdatePackDto) {
return this.adminPacksService.update(id, updatePackDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.adminPacksService.remove(id);
}
// Static routes must come BEFORE dynamic :id routes
@Get('export/template')
getTemplate() {
return {
templateVersion: '1.0',
instructions: 'Fill in your questions below. Each question must have a "question" field and an "answers" array with "text" and "points" fields.',
instructions:
'Fill in your questions below. Each question must have a "question" field and an "answers" array with "text" and "points" fields.',
questions: [
{
question: 'Your question here',
@ -69,11 +51,6 @@ export class AdminPacksController {
};
}
@Get(':id/export')
async exportPack(@Param('id') id: string) {
return this.adminPacksService.exportPack(id);
}
@Post('import')
async importPack(@Body() importPackDto: ImportPackDto, @Request() req) {
// Validate question structure
@ -99,4 +76,30 @@ export class AdminPacksController {
return this.adminPacksService.create(importPackDto, req.user.sub);
}
// Dynamic :id routes come after static routes
@Get(':id')
findOne(@Param('id') id: string) {
return this.adminPacksService.findOne(id);
}
@Get(':id/export')
async exportPack(@Param('id') id: string) {
return this.adminPacksService.exportPack(id);
}
@Post()
create(@Body() createPackDto: CreatePackDto, @Request() req) {
return this.adminPacksService.create(createPackDto, req.user.sub);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updatePackDto: UpdatePackDto) {
return this.adminPacksService.update(id, updatePackDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.adminPacksService.remove(id);
}
}

View file

@ -1,13 +1,16 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import { AdminRoomsService } from './admin-rooms.service';
import { RoomFiltersDto } from './dto/room-filters.dto';
import { CreateAdminRoomDto } from './dto/create-admin-room.dto';
import { AdminAuthGuard } from '../guards/admin-auth.guard';
import { AdminGuard } from '../guards/admin.guard';
@ -26,6 +29,11 @@ export class AdminRoomsController {
return this.adminRoomsService.findOne(id);
}
@Post()
create(@Body() dto: CreateAdminRoomDto) {
return this.adminRoomsService.createAdminRoom(dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.adminRoomsService.remove(id);

View file

@ -1,10 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RoomFiltersDto } from './dto/room-filters.dto';
import { CreateAdminRoomDto } from './dto/create-admin-room.dto';
import { RoomPackService } from '../../room-pack/room-pack.service';
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
@Injectable()
export class AdminRoomsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private roomPackService: RoomPackService,
) {}
async findAll(filters: RoomFiltersDto) {
const { status, dateFrom, dateTo, page = 1, limit = 10 } = filters;
@ -128,4 +136,92 @@ export class AdminRoomsService {
return { message: 'Room deleted successfully' };
}
async createAdminRoom(dto: CreateAdminRoomDto) {
// Проверить что customCode не занят (если указан)
if (dto.customCode) {
const existing = await this.prisma.room.findUnique({
where: { code: dto.customCode },
});
if (existing) {
throw new BadRequestException('Code already in use');
}
}
const code = dto.customCode || nanoid();
// НЕ устанавливать expiresAt если указаны activeFrom/activeTo
const expiresAt = (dto.activeFrom || dto.activeTo)
? null
: new Date(Date.now() + 24 * 60 * 60 * 1000);
// Подготовить настройки игры
const gameSettings = dto.settings || {};
const {
maxPlayers,
allowSpectators,
timerEnabled,
timerDuration,
...otherSettings
} = gameSettings;
const room = await this.prisma.room.create({
data: {
code,
hostId: dto.hostId,
expiresAt,
isAdminRoom: true,
customCode: dto.customCode || null,
activeFrom: dto.activeFrom ? new Date(dto.activeFrom) : null,
activeTo: dto.activeTo ? new Date(dto.activeTo) : null,
themeId: dto.themeId || null,
uiControls: dto.uiControls || null,
questionPackId: dto.questionPackId || null,
maxPlayers: maxPlayers || 10,
allowSpectators: allowSpectators !== undefined ? allowSpectators : true,
timerEnabled: timerEnabled || false,
timerDuration: timerDuration || 30,
...otherSettings,
},
include: {
host: {
select: {
id: true,
name: true,
email: true,
},
},
theme: {
select: {
id: true,
name: true,
isPublic: true,
},
},
questionPack: {
select: {
id: true,
name: true,
},
},
},
});
// Создать участника-хоста
const hostName = dto.hostName || room.host.name || 'Ведущий';
await this.prisma.participant.create({
data: {
userId: dto.hostId,
roomId: room.id,
name: hostName,
role: 'HOST',
},
});
// Создать RoomPack
await this.roomPackService.create(room.id, dto.questionPackId);
// Вернуть полную информацию о комнате
return this.findOne(room.id);
}
}

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

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

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

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

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

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateThemeDto } from './create-theme.dto';
export class UpdateThemeDto extends PartialType(CreateThemeDto) {}

View file

@ -10,6 +10,7 @@ import { GameModule } from './game/game.module';
import { StatsModule } from './stats/stats.module';
import { VoiceModule } from './voice/voice.module';
import { AdminModule } from './admin/admin.module';
import { ThemesModule } from './themes/themes.module';
@Module({
imports: [
@ -22,6 +23,7 @@ import { AdminModule } from './admin/admin.module';
StatsModule,
VoiceModule,
AdminModule,
ThemesModule,
],
controllers: [AppController],
providers: [AppService],

View file

@ -144,6 +144,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}
await this.broadcastFullState(payload.roomCode);
// Явно отправить событие начала игры для перенаправления всех игроков
this.server.to(payload.roomCode).emit('gameStarted', {
roomId: payload.roomId,
roomCode: payload.roomCode,
status: 'PLAYING'
});
}
@SubscribeMessage('playerAction')
@ -529,6 +536,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
// Проверить uiControls
const roomForControls = await this.prisma.room.findUnique({
where: { id: payload.roomId },
}) as any;
const uiControls = roomForControls?.uiControls as { allowPackChange?: boolean } | null;
if (uiControls && uiControls.allowPackChange === false) {
client.emit('error', { message: 'Pack editing is disabled for this room' });
return;
}
// Обновляем вопросы через service (который добавит UUID если нужно)
await this.roomsService.updateRoomPack(payload.roomId, payload.questions);
@ -589,6 +607,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
// Проверить uiControls
const roomForControls = await this.prisma.room.findUnique({
where: { id: payload.roomId },
}) as any;
const uiControls = roomForControls?.uiControls as { allowPackChange?: boolean } | null;
if (uiControls && uiControls.allowPackChange === false) {
client.emit('error', { message: 'Pack editing is disabled for this room' });
return;
}
await this.roomPackService.importQuestions(payload.roomId, payload.sourcePackId, payload.questionIndices);
await this.broadcastFullState(payload.roomCode);
}
@ -623,6 +652,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
// Проверить uiControls
const roomForControls = await this.prisma.room.findUnique({
where: { id: payload.roomId },
}) as any;
const uiControls = roomForControls?.uiControls as { allowNameChange?: boolean } | null;
if (uiControls && uiControls.allowNameChange === false) {
client.emit('error', { message: 'Name editing is disabled for this room' });
return;
}
if (!payload.newName || payload.newName.trim().length === 0) {
client.emit('error', { message: 'Name cannot be empty' });
return;
@ -655,6 +695,17 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
// Проверить uiControls
const roomForControls = await this.prisma.room.findUnique({
where: { id: payload.roomId },
}) as any;
const uiControls = roomForControls?.uiControls as { allowScoreEdit?: boolean } | null;
if (uiControls && uiControls.allowScoreEdit === false) {
client.emit('error', { message: 'Score editing is disabled for this room' });
return;
}
if (typeof payload.newScore !== 'number' || isNaN(payload.newScore)) {
client.emit('error', { message: 'Invalid score value' });
return;

View file

@ -6,8 +6,8 @@ export class RoomsController {
constructor(private roomsService: RoomsService) {}
@Post()
async createRoom(@Body() dto: { hostId: string; questionPackId?: string; settings?: any }) {
return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings);
async createRoom(@Body() dto: { hostId: string; questionPackId?: string; settings?: any; hostName?: string }) {
return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings, dto.hostName);
}
@Get(':code')

View file

@ -1,4 +1,4 @@
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { customAlphabet } from 'nanoid';
import { RoomEventsService } from '../game/room-events.service';
@ -15,7 +15,7 @@ export class RoomsService {
private roomPackService: RoomPackService,
) {}
async createRoom(hostId: string, questionPackId?: string, settings?: any) {
async createRoom(hostId: string, questionPackId?: string, settings?: any, hostName?: string) {
const code = nanoid();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
@ -24,6 +24,9 @@ export class RoomsService {
if ('questionPackId' in cleanSettings) {
delete cleanSettings.questionPackId;
}
if ('hostName' in cleanSettings) {
delete cleanSettings.hostName;
}
const room = await this.prisma.room.create({
data: {
@ -39,11 +42,14 @@ export class RoomsService {
},
});
// Используем переданное имя хоста или имя пользователя или дефолт "Ведущий"
const finalHostName = hostName?.trim() || room.host.name || 'Ведущий';
await this.prisma.participant.create({
data: {
userId: hostId,
roomId: room.id,
name: room.host.name || 'Host',
name: finalHostName,
role: 'HOST',
},
});
@ -56,7 +62,7 @@ export class RoomsService {
}
async getRoomByCode(code: string) {
return this.prisma.room.findUnique({
const room = await this.prisma.room.findUnique({
where: { code },
include: {
host: true,
@ -65,8 +71,24 @@ export class RoomsService {
},
questionPack: true,
roomPack: true,
theme: true,
},
});
if (!room) {
throw new NotFoundException('Room not found');
}
// Проверить период активности
const now = new Date();
if (room.activeFrom && now < room.activeFrom) {
throw new BadRequestException('Room not yet active');
}
if (room.activeTo && now > room.activeTo) {
throw new BadRequestException('Room is no longer active');
}
return room;
}
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
@ -114,11 +136,32 @@ export class RoomsService {
}
async updateQuestionPack(roomId: string, questionPackId: string) {
return this.prisma.room.update({
// Получаем вопросы из выбранного пака
const sourcePack = await this.prisma.questionPack.findUnique({
where: { id: questionPackId },
select: { questions: true },
});
// Копируем вопросы в roomPack
if (sourcePack && sourcePack.questions) {
await this.roomPackService.updateQuestions(
roomId,
Array.isArray(sourcePack.questions) ? sourcePack.questions as any[] : []
);
}
// Получаем обновленный roomPack для установки currentQuestionId
const roomPack = await this.roomPackService.findByRoomId(roomId);
const questions = roomPack?.questions as any[] || [];
const firstQuestionId = questions.length > 0 && questions[0].id ? questions[0].id : null;
// Обновляем комнату с новым паком и первым вопросом
const room = await this.prisma.room.update({
where: { id: roomId },
data: {
questionPackId,
currentQuestionIndex: 0,
currentQuestionId: firstQuestionId,
revealedAnswers: {},
},
include: {
@ -127,8 +170,14 @@ export class RoomsService {
include: { user: true },
},
questionPack: true,
roomPack: true,
},
});
// Отправляем обновление через WebSocket
this.roomEventsService.emitRoomUpdate(room.code, room);
return room;
}
async updateCustomQuestions(roomId: string, questions: any) {

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

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

View file

@ -110,7 +110,7 @@
}
.title-number {
color: #ffd700;
color: var(--accent-primary);
display: inline-block;
margin: 0 10px;
}
@ -133,7 +133,7 @@
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 15px;
padding: clamp(4px, 0.8vh, 6px) clamp(10px, 1.5vw, 15px);
color: #ffd700;
color: var(--accent-primary);
font-size: clamp(0.8rem, 1.8vw, 1rem);
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@ -168,7 +168,7 @@
.app-subtitle {
text-align: center;
color: #fff;
color: var(--text-primary);
font-size: clamp(1rem, 2vw, 1.5rem);
margin-bottom: clamp(5px, 1vh, 15px);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);

View file

@ -120,7 +120,7 @@
.answer-text {
font-size: clamp(0.9rem, 1.8vw, 1.4rem);
color: #fff;
color: var(--text-primary);
font-weight: bold;
text-align: center;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);

View file

@ -52,21 +52,21 @@
.game-over-title {
font-size: clamp(1.5rem, 4vw, 3rem);
color: #ffd700;
color: var(--accent-primary);
margin-bottom: clamp(10px, 2vh, 20px);
text-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
}
.game-over-score {
font-size: 3.5rem;
color: #fff;
color: var(--text-primary);
margin-bottom: 40px;
font-weight: bold;
}
.restart-button {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
color: white;
color: var(--text-primary);
border: none;
padding: clamp(10px, 2vh, 15px) clamp(30px, 5vw, 50px);
font-size: clamp(1rem, 2vw, 1.5rem);
@ -101,7 +101,7 @@
}
.no-players-message p {
color: #fff;
color: var(--text-primary);
font-size: clamp(1rem, 2vw, 1.3rem);
margin: 0;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
@ -115,7 +115,7 @@
}
.final-scores-title {
color: #fff;
color: var(--text-primary);
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
margin-bottom: clamp(10px, 2vh, 15px);
text-align: center;
@ -140,13 +140,13 @@
}
.final-score-name {
color: #fff;
color: var(--text-primary);
font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: 500;
}
.final-score-value {
color: #ffd700;
color: var(--accent-primary);
font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@ -154,7 +154,7 @@
.final-score-winner .final-score-name,
.final-score-winner .final-score-value {
color: #ffd700;
color: var(--accent-primary);
text-shadow: 0 0 15px rgba(255, 215, 0, 0.8);
}

View file

@ -232,7 +232,7 @@
.player-edit-save {
background: var(--accent-success, #4ecdc4);
color: white;
color: var(--text-primary);
}
.player-edit-save:hover {
@ -341,7 +341,7 @@
.start-button {
background: var(--accent-success, #4ecdc4);
border-color: var(--accent-success, #4ecdc4);
color: white;
color: var(--text-primary);
width: 100%;
font-size: 1.1rem;
}
@ -349,7 +349,7 @@
.end-button {
background: var(--accent-secondary, #ff6b6b);
border-color: var(--accent-secondary, #ff6b6b);
color: white;
color: var(--text-primary);
}
.toggle-all-button {
@ -383,7 +383,7 @@
.answer-button.revealed {
background: var(--accent-success, #4ecdc4);
border-color: var(--accent-success, #4ecdc4);
color: white;
color: var(--text-primary);
}
.answer-button.hidden {
@ -417,7 +417,7 @@
}
.answer-button.revealed .answer-pts {
color: white;
color: var(--text-primary);
}
/* Scoring tab */
@ -462,13 +462,13 @@
.points-button {
background: var(--accent-success, #4ecdc4);
border-color: var(--accent-success, #4ecdc4);
color: white;
color: var(--text-primary);
}
.penalty-button {
background: var(--accent-secondary, #ff6b6b);
border-color: var(--accent-secondary, #ff6b6b);
color: white;
color: var(--text-primary);
}
.custom-points {
@ -525,7 +525,8 @@
.questions-tab-content .questions-modal-export-button,
.questions-tab-content .questions-modal-import-button,
.questions-tab-content .questions-modal-pack-import-button {
.questions-tab-content .questions-modal-pack-import-button,
.questions-tab-content .questions-modal-template-button {
padding: 0.5rem 1rem;
background: var(--bg-card, #1a1a1a);
border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
@ -539,7 +540,8 @@
.questions-tab-content .questions-modal-export-button:hover,
.questions-tab-content .questions-modal-import-button:hover,
.questions-tab-content .questions-modal-pack-import-button:hover {
.questions-tab-content .questions-modal-pack-import-button:hover,
.questions-tab-content .questions-modal-template-button:hover {
border-color: var(--accent-primary, #ffd700);
transform: translateY(-2px);
}
@ -586,7 +588,7 @@
background: var(--accent-success, #4ecdc4);
border: none;
border-radius: var(--border-radius-sm, 8px);
color: white;
color: var(--text-primary);
font-weight: 600;
cursor: pointer;
}
@ -715,7 +717,7 @@
.questions-tab-content .questions-modal-save-button {
background: var(--accent-success, #4ecdc4);
color: white;
color: var(--text-primary);
border-color: var(--accent-success, #4ecdc4);
}

View file

@ -257,6 +257,49 @@ const GameManagementModal = ({
}
}
const handleDownloadTemplate = () => {
const template = [
{
text: 'Назовите самый популярный вид спорта в мире',
answers: [
{ text: 'Футбол', points: 100 },
{ text: 'Баскетбол', points: 80 },
{ text: 'Теннис', points: 60 },
{ text: 'Хоккей', points: 40 },
{ text: 'Волейбол', points: 20 },
{ text: 'Бокс', points: 10 },
]
},
{
text: 'Что люди обычно берут с собой на пляж?',
answers: [
{ text: 'Полотенце', points: 100 },
{ text: 'Крем от солнца', points: 80 },
{ text: 'Очки', points: 60 },
{ text: 'Зонт', points: 40 },
{ text: 'Книга', points: 20 },
{ text: 'Еда', points: 10 },
]
}
]
try {
const jsonString = JSON.stringify(template, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'template_questions.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
setJsonError('')
} catch (error) {
setJsonError('Ошибка при скачивании шаблона: ' + error.message)
}
}
const handleImportJson = () => {
const input = document.createElement('input')
input.type = 'file'
@ -275,21 +318,31 @@ const GameManagementModal = ({
return
}
// Валидация - id опционален (будет сгенерирован автоматически)
const isValid = jsonContent.every(q =>
q.id &&
typeof q.text === 'string' &&
Array.isArray(q.answers) &&
q.answers.every(a => a.text && typeof a.points === 'number')
)
if (!isValid) {
setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: id, text, answers')
setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: text, answers')
return
}
onUpdateQuestions(jsonContent)
// Добавляем id если его нет
const questionsWithIds = jsonContent.map((q, idx) => ({
...q,
id: q.id || Date.now() + Math.random() + idx,
answers: q.answers.map((a, aidx) => ({
...a,
id: a.id || `answer-${Date.now()}-${idx}-${aidx}`
}))
}))
onUpdateQuestions(questionsWithIds)
setJsonError('')
alert(`Успешно импортировано ${jsonContent.length} вопросов`)
alert(`Успешно импортировано ${questionsWithIds.length} вопросов`)
} catch (error) {
setJsonError('Ошибка при импорте: ' + error.message)
}
@ -717,10 +770,10 @@ const GameManagementModal = ({
<div className="questions-modal-actions">
<button
className="questions-modal-export-button"
onClick={handleExportJson}
className="questions-modal-template-button"
onClick={handleDownloadTemplate}
>
📥 Экспорт JSON
📋 Скачать шаблон
</button>
<button
className="questions-modal-import-button"
@ -728,6 +781,12 @@ const GameManagementModal = ({
>
📤 Импорт JSON
</button>
<button
className="questions-modal-export-button"
onClick={handleExportJson}
>
📥 Экспорт JSON
</button>
{availablePacks.length > 0 && (
<button
className="questions-modal-pack-import-button"

View file

@ -109,7 +109,7 @@
.admin-button-start {
background: var(--accent-success);
border-color: var(--accent-success);
color: #ffffff;
color: var(--text-primary);
}
.admin-button-start:hover:not(:disabled) {
@ -120,7 +120,7 @@
.admin-button-end {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
color: #ffffff;
color: var(--text-primary);
}
.admin-button-end:hover:not(:disabled) {
@ -174,7 +174,7 @@
.answer-control-button.revealed {
background: var(--accent-success);
border-color: var(--accent-success);
color: #ffffff;
color: var(--text-primary);
}
.answer-control-button.hidden {
@ -212,7 +212,7 @@
}
.answer-control-button.revealed .answer-points {
color: #ffffff;
color: var(--text-primary);
}
/* Scoring Controls */
@ -265,7 +265,7 @@
.admin-button-success {
background: var(--accent-success);
border-color: var(--accent-success);
color: #ffffff;
color: var(--text-primary);
}
.admin-button-success:hover:not(:disabled) {
@ -275,7 +275,7 @@
.admin-button-danger {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
color: #ffffff;
color: var(--text-primary);
}
.admin-button-danger:hover:not(:disabled) {

View file

@ -33,7 +33,7 @@
}
.name-input-modal-title {
color: #ffd700;
color: var(--accent-primary);
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
margin: 0;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@ -94,7 +94,7 @@
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 12px;
color: #ffffff;
color: var(--text-primary);
font-size: 1.1rem;
transition: all 0.3s ease;
box-sizing: border-box;
@ -102,7 +102,7 @@
.name-input-field:focus {
outline: none;
border-color: #ffd700;
border-color: var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.3);
}
@ -137,13 +137,13 @@
.name-input-submit-button {
background: rgba(255, 215, 0, 0.2);
color: #ffd700;
color: var(--accent-primary);
border-color: rgba(255, 215, 0, 0.5);
}
.name-input-submit-button:hover:not(:disabled) {
background: rgba(255, 215, 0, 0.3);
border-color: #ffd700;
border-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
}

View file

@ -32,20 +32,20 @@
.player-item.player-active {
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);
transform: translateY(-2px);
}
.player-name {
color: #fff;
color: var(--text-primary);
font-size: 1rem;
font-weight: 500;
white-space: nowrap;
}
.player-item.player-active .player-name {
color: #ffd700;
color: var(--accent-primary);
font-weight: 600;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
}
@ -59,7 +59,7 @@
}
.player-item.player-active .player-score {
color: #ffd700;
color: var(--accent-primary);
font-size: 1.2rem;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
}

View file

@ -35,7 +35,7 @@
}
.players-modal-title {
color: #ffd700;
color: var(--accent-primary);
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
margin: 0;
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-radius: 12px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
@ -87,7 +87,7 @@
}
.players-modal-input:focus {
border-color: #ffd700;
border-color: var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
@ -95,7 +95,7 @@
.players-modal-add-button {
padding: 15px 30px;
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
color: var(--text-primary);
border: none;
border-radius: 12px;
font-size: 1rem;
@ -137,7 +137,7 @@
}
.players-modal-item-name {
color: #fff;
color: var(--text-primary);
font-size: 1.2rem;
font-weight: 500;
flex: 1;

View file

@ -35,7 +35,7 @@
}
.qr-modal-title {
color: #ffd700;
color: var(--accent-primary);
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
margin: 0;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@ -112,7 +112,7 @@
}
.qr-modal-code-value {
color: #ffd700;
color: var(--accent-primary);
font-size: 1.5rem;
font-weight: bold;
margin: 0;

View file

@ -42,7 +42,7 @@
border: 2px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
color: #fff;
color: var(--text-primary);
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: bold;
cursor: pointer;
@ -83,14 +83,14 @@
padding: clamp(6px, 1vh, 10px) clamp(15px, 2vw, 25px);
margin-bottom: clamp(10px, 2vh, 15px);
font-size: clamp(1rem, 2vw, 1.4rem);
color: #ffd700;
color: var(--accent-primary);
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.question-text {
color: #fff;
color: var(--text-primary);
font-size: clamp(1.2rem, 3vw, 2.5rem);
font-weight: bold;
line-height: 1.3;

View file

@ -35,7 +35,7 @@
}
.questions-modal-title {
color: #ffd700;
color: var(--accent-primary);
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
margin: 0;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@ -75,7 +75,7 @@
flex: 1;
padding: 12px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
color: var(--text-primary);
border: none;
border-radius: 12px;
font-size: 1rem;
@ -122,7 +122,7 @@
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
@ -134,7 +134,7 @@
}
.questions-modal-input:focus {
border-color: #ffd700;
border-color: var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
@ -148,7 +148,7 @@
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
color: #fff;
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 500;
}
@ -182,7 +182,7 @@
border: 2px solid rgba(255, 215, 0, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
transition: all 0.3s ease;
@ -193,7 +193,7 @@
}
.questions-modal-answer-input:focus {
border-color: #ffd700;
border-color: var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
}
@ -203,7 +203,7 @@
border: 2px solid rgba(255, 215, 0, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
transition: all 0.3s ease;
@ -211,7 +211,7 @@
}
.questions-modal-points-input:focus {
border-color: #ffd700;
border-color: var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
}
@ -247,7 +247,7 @@
flex: 1;
padding: 15px 30px;
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
color: var(--text-primary);
border: none;
border-radius: 12px;
font-size: 1rem;
@ -268,7 +268,7 @@
.questions-modal-cancel-button {
padding: 15px 30px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
font-size: 1rem;
@ -287,7 +287,7 @@
}
.questions-modal-list-title {
color: #ffd700;
color: var(--accent-primary);
font-size: 1.5rem;
margin-bottom: 15px;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@ -323,7 +323,7 @@
}
.questions-modal-item-text {
color: #fff;
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 5px;
@ -438,7 +438,7 @@
flex: 1;
padding: 12px 20px;
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
color: white;
color: var(--text-primary);
border: none;
border-radius: 12px;
font-size: 1rem;
@ -461,7 +461,7 @@
}
.pack-import-section h3 {
color: #ffd700;
color: var(--accent-primary);
font-size: 1.3rem;
margin: 0 0 15px 0;
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-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 1rem;
outline: none;
cursor: pointer;
@ -482,14 +482,14 @@
}
.pack-import-select:focus {
border-color: #ffd700;
border-color: var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
.pack-import-select option {
background: #1a1a2e;
color: #fff;
color: var(--text-primary);
padding: 10px;
}
@ -504,14 +504,14 @@
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid rgba(255, 215, 0, 0.2);
color: #fff;
color: var(--text-primary);
font-size: 1.1rem;
}
.pack-import-confirm-button {
padding: 10px 20px;
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
color: var(--text-primary);
border: none;
border-radius: 8px;
font-size: 0.95rem;
@ -570,7 +570,7 @@
}
.pack-question-content strong {
color: #fff;
color: var(--text-primary);
font-size: 1rem;
display: block;
margin-bottom: 5px;
@ -593,7 +593,7 @@
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
@ -604,7 +604,7 @@
}
.pack-search-input:focus {
border-color: #ffd700;
border-color: var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
@ -708,7 +708,7 @@
}
.pack-question-viewer-header h4 {
color: #ffd700;
color: var(--accent-primary);
font-size: 1.5rem;
margin: 0;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
@ -744,7 +744,7 @@
}
.pack-question-viewer-text {
color: #fff;
color: var(--text-primary);
font-size: 1.2rem;
line-height: 1.6;
padding: 15px;
@ -756,7 +756,7 @@
.pack-show-answers-button {
padding: 12px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
color: var(--text-primary);
border: none;
border-radius: 12px;
font-size: 1rem;
@ -795,7 +795,7 @@
}
.pack-answer-text {
color: #fff;
color: var(--text-primary);
font-size: 1rem;
flex: 1;
word-wrap: break-word;
@ -803,7 +803,7 @@
}
.pack-answer-points {
color: #ffd700;
color: var(--accent-primary);
font-size: 1rem;
font-weight: bold;
flex-shrink: 0;

View file

@ -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, setSnowflakes] = useState([])
// Initialize snowflakes
useEffect(() => {
const createSnowflake = () => {
const snowflake = {
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)
const initial = Array.from({ length: TARGET_COUNT }, (_, i) => createSnowflake(i))
setSnowflakes(initial)
}, [])
// Update cycle - remove old snowflakes and add new ones
useEffect(() => {
const interval = setInterval(() => {
setSnowflakes((prev) => {
const newFlakes = prev.filter(
(flake) => flake.id > Math.random() * 0.1
)
return [...newFlakes, createSnowflake()]
const now = Date.now()
// Filter out snowflakes that have exceeded their lifetime
// 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
})
// Add new snowflakes if below target
const newFlakes = [...filtered]
while (newFlakes.length < TARGET_COUNT) {
newFlakes.push(createSnowflake())
}
return newFlakes
})
}, 3000)
}, UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [])
return (
<>
<div className="snowflakes-container">
{snowflakes.map((snowflake) => (
<div
key={snowflake.id}
className="snowflake"
style={{
left: `${snowflake.left}%`,
animationDuration: `${snowflake.animationDuration}s`,
animationDuration: `${snowflake.duration}s`,
animationDelay: `${snowflake.delay}s`,
fontSize: `${snowflake.size}px`,
opacity: snowflake.opacity,
}}
>
</div>
))}
</>
</div>
)
}
export default Snowflakes

View file

@ -220,7 +220,7 @@
.voice-settings-effect-button.effect-correct:hover {
background: var(--accent-success);
color: #ffffff;
color: var(--text-primary);
}
.voice-settings-effect-button.effect-error {
@ -229,7 +229,7 @@
.voice-settings-effect-button.effect-error:hover {
background: var(--accent-secondary);
color: #ffffff;
color: var(--text-primary);
}
.voice-settings-effect-button.effect-victory {

View file

@ -2,33 +2,44 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext();
export const themes = {
// Built-in themes
export const BUILT_IN_THEMES = {
'new-year': {
id: 'new-year',
name: 'Новый год',
icon: '🎄',
description: 'Праздничная новогодняя тема с золотым свечением',
isBuiltIn: true,
},
family: {
id: 'family',
name: 'Семейная',
icon: '🏠',
description: 'Светлая и уютная тема для семейной игры',
isBuiltIn: true,
},
party: {
id: 'party',
name: 'Вечеринка',
icon: '🎉',
description: 'Яркая энергичная тема для шумных компаний',
isBuiltIn: true,
},
dark: {
id: 'dark',
name: 'Темная',
icon: '🌙',
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 = () => {
const context = useContext(ThemeContext);
if (!context) {
@ -40,25 +51,105 @@ export const useTheme = () => {
export const ThemeProvider = ({ children }) => {
const [currentTheme, setCurrentTheme] = useState(() => {
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(() => {
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);
}, [currentTheme]);
}, [currentTheme, customThemes]);
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);
}
};
// 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 = {
currentTheme,
currentThemeData: themes[currentTheme],
themes,
currentThemeData: allThemes[currentTheme] || BUILT_IN_THEMES['new-year'],
themes: allThemes,
builtInThemes: BUILT_IN_THEMES,
customThemes,
changeTheme,
loading,
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

View file

@ -60,6 +60,16 @@ export const useRoom = (roomCode, onGameStarted = null) => {
if (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);
@ -73,9 +83,9 @@ export const useRoom = (roomCode, onGameStarted = null) => {
};
}, [roomCode, onGameStarted, user?.id]);
const createRoom = useCallback(async (hostId, questionPackId, settings = {}) => {
const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => {
try {
const response = await roomsApi.create(hostId, questionPackId, settings);
const response = await roomsApi.create(hostId, questionPackId, settings, hostName);
setRoom(response.data);
return response.data;
} catch (err) {

View file

@ -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;
padding: 0;
@ -10,7 +77,7 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%);
background: var(--bg-primary);
min-height: 100vh;
max-height: 100vh;
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 {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 0;
}
2% {
10% {
opacity: 1;
}
98% {
90% {
opacity: 1;
}
100% {
@ -45,18 +123,14 @@ body {
}
.snowflake {
position: fixed;
top: 0;
color: white;
position: absolute;
top: -20px;
color: var(--text-primary);
font-size: 1em;
font-family: Arial;
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
animation-name: snow;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
animation: snow linear forwards;
pointer-events: none;
z-index: 1;
will-change: transform, opacity;
}
@ -107,7 +181,7 @@ body {
.welcome-text {
text-align: center;
color: #fff;
color: var(--text-primary);
margin-bottom: 2rem;
font-size: 1.2rem;
}
@ -125,7 +199,7 @@ body {
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 1rem 2rem;
color: #fff;
color: var(--text-primary);
font-size: 1rem;
font-weight: bold;
cursor: pointer;
@ -157,7 +231,7 @@ body {
.user-stats {
text-align: center;
color: #fff;
color: var(--text-primary);
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
@ -174,7 +248,7 @@ body {
.form-group label {
display: block;
color: #fff;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-weight: 500;
}
@ -186,7 +260,7 @@ body {
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
@ -222,6 +296,14 @@ body {
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 {
display: flex;
gap: 1rem;
@ -235,7 +317,7 @@ body {
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 0.75rem 1.5rem;
color: #fff;
color: var(--text-primary);
font-size: 1rem;
font-weight: bold;
cursor: pointer;
@ -273,7 +355,7 @@ body {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.3);
color: #fff;
color: var(--text-primary);
}
.button-group button.secondary:hover:not(:disabled) {
@ -302,7 +384,7 @@ body {
}
.pack-info p {
color: #fff;
color: var(--text-primary);
margin: 0.5rem 0;
}
@ -317,7 +399,7 @@ body {
}
.pack-warning {
color: #ffd700;
color: var(--accent-primary);
font-weight: 500;
}
@ -336,7 +418,7 @@ body {
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
@ -355,7 +437,7 @@ body {
.pack-selector select option {
background: #1a1f3a;
color: #fff;
color: var(--text-primary);
}
.pack-selector button {
@ -364,7 +446,7 @@ body {
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 0.75rem 1.5rem;
color: #fff;
color: var(--text-primary);
font-size: 1rem;
font-weight: bold;
cursor: pointer;
@ -392,7 +474,7 @@ body {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.3);
color: #fff;
color: var(--text-primary);
}
.pack-selector button.secondary:hover:not(:disabled) {
@ -413,6 +495,34 @@ body {
.pack-selector button {
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,
@ -423,7 +533,7 @@ body {
align-items: center;
justify-content: center;
padding: 2rem;
color: #fff;
color: var(--text-primary);
text-align: center;
}
@ -445,7 +555,7 @@ body {
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 0.75rem 1.5rem;
color: #fff;
color: var(--text-primary);
font-size: 1rem;
font-weight: bold;
cursor: pointer;
@ -467,7 +577,7 @@ body {
}
.room-info p {
color: #fff;
color: var(--text-primary);
margin: 0.5rem 0;
font-size: 1rem;
}
@ -516,7 +626,7 @@ body {
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
color: #fff;
color: var(--text-primary);
font-size: 1rem;
}

View file

@ -12,6 +12,7 @@ const CreateRoom = () => {
const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState('');
const [hostName, setHostName] = useState('');
const [settings, setSettings] = useState({
maxPlayers: 10,
allowSpectators: true,
@ -71,6 +72,7 @@ const CreateRoom = () => {
user.id,
selectedPackId || undefined,
settings,
hostName.trim() || undefined,
);
navigate(`/room/${room.code}`);
} catch (error) {
@ -88,6 +90,18 @@ const CreateRoom = () => {
<div className="create-room-container">
<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">
<label>Выберите пак вопросов (можно добавить позже):</label>
<select

View file

@ -74,7 +74,7 @@
.manage-questions-button {
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
color: var(--text-primary);
border: none;
border-radius: 12px;
font-size: 1rem;

View file

@ -16,8 +16,8 @@ export const authApi = {
// Rooms endpoints
export const roomsApi = {
create: (hostId, questionPackId, settings) =>
api.post('/rooms', { hostId, questionPackId, settings }),
create: (hostId, questionPackId, settings, hostName) =>
api.post('/rooms', { hostId, questionPackId, settings, hostName }),
getByCode: (code) => api.get(`/rooms/${code}`),
join: (roomId, userId, name, role) =>
api.post(`/rooms/${roomId}/join`, { userId, name, role }),