admin
This commit is contained in:
parent
73315bcf45
commit
96577926c8
47 changed files with 3640 additions and 190 deletions
|
|
@ -4,6 +4,8 @@ import LoginPage from '@/pages/LoginPage'
|
|||
import DashboardPage from '@/pages/DashboardPage'
|
||||
import 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
192
admin/src/api/rooms.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface RoomDto {
|
||||
id: string
|
||||
code: string
|
||||
status: 'WAITING' | 'PLAYING' | 'FINISHED'
|
||||
hostId: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
isAdminRoom: boolean
|
||||
customCode?: string
|
||||
activeFrom?: string
|
||||
activeTo?: string
|
||||
themeId?: string
|
||||
questionPackId?: string
|
||||
uiControls?: {
|
||||
allowThemeChange?: boolean
|
||||
allowPackChange?: boolean
|
||||
allowNameChange?: boolean
|
||||
allowScoreEdit?: boolean
|
||||
}
|
||||
maxPlayers: number
|
||||
allowSpectators: boolean
|
||||
timerEnabled: boolean
|
||||
timerDuration: number
|
||||
host: {
|
||||
id: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
theme?: {
|
||||
id: string
|
||||
name: string
|
||||
isPublic: boolean
|
||||
}
|
||||
questionPack?: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
_count?: {
|
||||
participants: number
|
||||
}
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
}
|
||||
|
||||
export interface CreateAdminRoomDto {
|
||||
hostId: string
|
||||
hostName?: string
|
||||
customCode?: string
|
||||
activeFrom?: string
|
||||
activeTo?: string
|
||||
themeId?: string
|
||||
questionPackId?: string
|
||||
uiControls?: {
|
||||
allowThemeChange?: boolean
|
||||
allowPackChange?: boolean
|
||||
allowNameChange?: boolean
|
||||
allowScoreEdit?: boolean
|
||||
}
|
||||
settings?: {
|
||||
maxPlayers?: number
|
||||
allowSpectators?: boolean
|
||||
timerEnabled?: boolean
|
||||
timerDuration?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoomsResponse {
|
||||
rooms: RoomDto[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface RoomsApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
originalError?: unknown
|
||||
name: 'RoomsApiError'
|
||||
}
|
||||
|
||||
export function createRoomsApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
originalError?: unknown
|
||||
): RoomsApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
originalError,
|
||||
name: 'RoomsApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isRoomsApiError(error: unknown): error is RoomsApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'RoomsApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const roomsApi = {
|
||||
getRooms: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
status?: 'WAITING' | 'PLAYING' | 'FINISHED'
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}): Promise<RoomsResponse> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/admin/rooms', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
status: params?.status,
|
||||
dateFrom: params?.dateFrom,
|
||||
dateTo: params?.dateTo,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
throw createRoomsApiError(
|
||||
axiosError.response?.data?.message || 'Failed to load rooms',
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getRoom: async (roomId: string): Promise<RoomDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/admin/rooms/${roomId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createRoomsApiError(
|
||||
`Room not found: The room with ID "${roomId}" does not exist.`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createRoomsApiError(
|
||||
axiosError.response?.data?.message || `Failed to load room ${roomId}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
createAdminRoom: async (room: CreateAdminRoomDto): Promise<RoomDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.post('/api/admin/rooms', room)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
throw createRoomsApiError(
|
||||
axiosError.response?.data?.message || 'Failed to create room',
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
deleteRoom: async (roomId: string): Promise<{ message: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/admin/rooms/${roomId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createRoomsApiError(
|
||||
`Room not found: The room with ID "${roomId}" does not exist.`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createRoomsApiError(
|
||||
axiosError.response?.data?.message || `Failed to delete room ${roomId}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
238
admin/src/api/themes.ts
Normal file
238
admin/src/api/themes.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface ThemeColors {
|
||||
bgPrimary: string
|
||||
bgOverlay: string
|
||||
bgCard: string
|
||||
bgCardHover: string
|
||||
textPrimary: string
|
||||
textSecondary: string
|
||||
textGlow: string
|
||||
accentPrimary: string
|
||||
accentSecondary: string
|
||||
accentSuccess: string
|
||||
borderColor: string
|
||||
borderGlow: string
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
shadowSm: string
|
||||
shadowMd: string
|
||||
shadowLg: string
|
||||
blurAmount: string
|
||||
borderRadiusSm: string
|
||||
borderRadiusMd: string
|
||||
borderRadiusLg: string
|
||||
animationSpeed: string
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string
|
||||
name: string
|
||||
isPublic: boolean
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
creator?: {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ThemePreview {
|
||||
id: string
|
||||
name: string
|
||||
isPublic: boolean
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
createdAt: string
|
||||
creator?: {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateThemeDto {
|
||||
name: string
|
||||
isPublic?: boolean
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
}
|
||||
|
||||
export interface UpdateThemeDto {
|
||||
name?: string
|
||||
isPublic?: boolean
|
||||
colors?: ThemeColors
|
||||
settings?: ThemeSettings
|
||||
}
|
||||
|
||||
export interface PaginatedThemesResponse {
|
||||
themes: ThemePreview[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface ThemesApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
originalError?: unknown
|
||||
name: 'ThemesApiError'
|
||||
}
|
||||
|
||||
export function createThemesApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
originalError?: unknown
|
||||
): ThemesApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
originalError,
|
||||
name: 'ThemesApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isThemesApiError(error: unknown): error is ThemesApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'ThemesApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const themesApi = {
|
||||
getThemes: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
isPublic?: boolean
|
||||
}): Promise<PaginatedThemesResponse> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/admin/themes', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
search: params?.search,
|
||||
isPublic: params?.isPublic,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
throw createThemesApiError(
|
||||
axiosError.response?.data?.message || 'Failed to load themes',
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getTheme: async (themeId: string): Promise<Theme> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/admin/themes/${themeId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createThemesApiError(
|
||||
`Theme not found: The theme with ID "${themeId}" does not exist.`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createThemesApiError(
|
||||
axiosError.response?.data?.message || `Failed to load theme ${themeId}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
createTheme: async (theme: CreateThemeDto): Promise<Theme> => {
|
||||
try {
|
||||
const response = await adminApiClient.post('/api/admin/themes', theme)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
throw createThemesApiError(
|
||||
axiosError.response?.data?.message || 'Failed to create theme',
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
updateTheme: async (themeId: string, theme: UpdateThemeDto): Promise<Theme> => {
|
||||
try {
|
||||
const response = await adminApiClient.patch(`/api/admin/themes/${themeId}`, theme)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createThemesApiError(
|
||||
`Theme not found: The theme with ID "${themeId}" does not exist.`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createThemesApiError(
|
||||
axiosError.response?.data?.message || `Failed to update theme ${themeId}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
deleteTheme: async (themeId: string): Promise<{ message: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/admin/themes/${themeId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createThemesApiError(
|
||||
`Theme not found: The theme with ID "${themeId}" does not exist.`,
|
||||
axiosError.response.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw createThemesApiError(
|
||||
axiosError.response?.data?.message || `Failed to delete theme ${themeId}`,
|
||||
axiosError.response?.status,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_COLORS: ThemeColors = {
|
||||
bgPrimary: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
bgOverlay: 'rgba(0, 0, 0, 0.7)',
|
||||
bgCard: 'rgba(255, 255, 255, 0.1)',
|
||||
bgCardHover: 'rgba(255, 255, 255, 0.2)',
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.8)',
|
||||
textGlow: '#ffd700',
|
||||
accentPrimary: '#ffd700',
|
||||
accentSecondary: '#ffed4e',
|
||||
accentSuccess: '#4ade80',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderGlow: 'rgba(255, 215, 0, 0.3)',
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
|
||||
shadowSm: '0 1px 2px rgba(0, 0, 0, 0.1)',
|
||||
shadowMd: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
shadowLg: '0 10px 15px rgba(0, 0, 0, 0.2)',
|
||||
blurAmount: '10px',
|
||||
borderRadiusSm: '4px',
|
||||
borderRadiusMd: '8px',
|
||||
borderRadiusLg: '12px',
|
||||
animationSpeed: '0.3s',
|
||||
}
|
||||
483
admin/src/components/CreateAdminRoomDialog.tsx
Normal file
483
admin/src/components/CreateAdminRoomDialog.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { roomsApi, type CreateAdminRoomDto } from '@/api/rooms'
|
||||
import { usersApi } from '@/api/users'
|
||||
import { themesApi } from '@/api/themes'
|
||||
import { packsApi } from '@/api/packs'
|
||||
import type { AxiosError } from 'axios'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface CreateAdminRoomDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function CreateAdminRoomDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateAdminRoomDialogProps) {
|
||||
const [formData, setFormData] = useState<CreateAdminRoomDto>({
|
||||
hostId: '',
|
||||
hostName: 'Ведущий',
|
||||
customCode: '',
|
||||
activeFrom: undefined,
|
||||
activeTo: undefined,
|
||||
themeId: undefined,
|
||||
questionPackId: undefined,
|
||||
uiControls: {
|
||||
allowThemeChange: true,
|
||||
allowPackChange: true,
|
||||
allowNameChange: true,
|
||||
allowScoreEdit: true,
|
||||
},
|
||||
settings: {
|
||||
maxPlayers: 10,
|
||||
allowSpectators: true,
|
||||
timerEnabled: false,
|
||||
timerDuration: 30,
|
||||
},
|
||||
})
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
// Fetch data
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['users', 1],
|
||||
queryFn: () => usersApi.getUsers({ page: 1, limit: 100 }),
|
||||
})
|
||||
|
||||
const { data: themesData } = useQuery({
|
||||
queryKey: ['themes'],
|
||||
queryFn: () => themesApi.getThemes({ page: 1, limit: 100 }),
|
||||
})
|
||||
|
||||
const { data: packsData } = useQuery({
|
||||
queryKey: ['packs', 1],
|
||||
queryFn: () => packsApi.getPacks({ page: 1, limit: 100 }),
|
||||
})
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateAdminRoomDto) => roomsApi.createAdminRoom(data),
|
||||
onSuccess: () => {
|
||||
toast.success('Room created successfully')
|
||||
onSuccess()
|
||||
// Reset form
|
||||
setFormData({
|
||||
hostId: '',
|
||||
hostName: 'Ведущий',
|
||||
customCode: '',
|
||||
activeFrom: undefined,
|
||||
activeTo: undefined,
|
||||
themeId: undefined,
|
||||
questionPackId: undefined,
|
||||
uiControls: {
|
||||
allowThemeChange: true,
|
||||
allowPackChange: true,
|
||||
allowNameChange: true,
|
||||
allowScoreEdit: true,
|
||||
},
|
||||
settings: {
|
||||
maxPlayers: 10,
|
||||
allowSpectators: true,
|
||||
timerEnabled: false,
|
||||
timerDuration: 30,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to create room')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.hostId) {
|
||||
toast.error('Please select a host')
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare data - remove undefined values
|
||||
const submitData: CreateAdminRoomDto = {
|
||||
hostId: formData.hostId,
|
||||
hostName: formData.hostName || undefined,
|
||||
customCode: formData.customCode || undefined,
|
||||
activeFrom: formData.activeFrom || undefined,
|
||||
activeTo: formData.activeTo || undefined,
|
||||
themeId: formData.themeId || undefined,
|
||||
questionPackId: formData.questionPackId || undefined,
|
||||
uiControls: formData.uiControls,
|
||||
settings: formData.settings,
|
||||
}
|
||||
|
||||
createMutation.mutate(submitData)
|
||||
}
|
||||
|
||||
const formatDateTimeLocal = (dateStr?: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const parseDateTimeLocal = (value: string) => {
|
||||
if (!value) return undefined
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Admin Room</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new game room with custom settings
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Host Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hostId">Host *</Label>
|
||||
<Select
|
||||
value={formData.hostId}
|
||||
onValueChange={(value) => setFormData({ ...formData, hostId: value })}
|
||||
>
|
||||
<SelectTrigger id="hostId">
|
||||
<SelectValue placeholder="Select a host" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{usersData?.items.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name || 'No name'} ({user.email || user.telegramId || 'No contact'})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Host Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hostName">Host Name in Game</Label>
|
||||
<Input
|
||||
id="hostName"
|
||||
value={formData.hostName}
|
||||
onChange={(e) => setFormData({ ...formData, hostName: e.target.value })}
|
||||
placeholder="Ведущий"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Code */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customCode">Custom Room Code (optional)</Label>
|
||||
<Input
|
||||
id="customCode"
|
||||
value={formData.customCode}
|
||||
onChange={(e) => setFormData({ ...formData, customCode: e.target.value })}
|
||||
placeholder="Leave empty for random code"
|
||||
maxLength={6}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty to generate a random 6-character code
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Time Period */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Active Period (optional)</CardTitle>
|
||||
<CardDescription>
|
||||
Set when the room should be available
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="activeFrom">Available From</Label>
|
||||
<Input
|
||||
id="activeFrom"
|
||||
type="datetime-local"
|
||||
value={formData.activeFrom ? formatDateTimeLocal(formData.activeFrom) : ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
activeFrom: parseDateTimeLocal(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="activeTo">Available To</Label>
|
||||
<Input
|
||||
id="activeTo"
|
||||
type="datetime-local"
|
||||
value={formData.activeTo ? formatDateTimeLocal(formData.activeTo) : ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
activeTo: parseDateTimeLocal(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Theme Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="themeId">Theme</Label>
|
||||
<Select
|
||||
value={formData.themeId || ''}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, themeId: value || undefined })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="themeId">
|
||||
<SelectValue placeholder="Default theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Default theme</SelectItem>
|
||||
{themesData?.themes.map((theme) => (
|
||||
<SelectItem key={theme.id} value={theme.id}>
|
||||
{theme.name} {theme.isPublic ? '(Public)' : '(Private)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Question Pack */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="questionPackId">Question Pack</Label>
|
||||
<Select
|
||||
value={formData.questionPackId || ''}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, questionPackId: value || undefined })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="questionPackId">
|
||||
<SelectValue placeholder="No questions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No questions</SelectItem>
|
||||
{packsData?.items.map((pack) => (
|
||||
<SelectItem key={pack.id} value={pack.id}>
|
||||
{pack.title} ({pack.cards} questions)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* UI Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">UI Controls</CardTitle>
|
||||
<CardDescription>
|
||||
Control which features are available to players and host
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowThemeChange"
|
||||
checked={formData.uiControls?.allowThemeChange}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
uiControls: {
|
||||
...formData.uiControls,
|
||||
allowThemeChange: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allowThemeChange" className="cursor-pointer">
|
||||
Allow theme change
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowPackChange"
|
||||
checked={formData.uiControls?.allowPackChange}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
uiControls: {
|
||||
...formData.uiControls,
|
||||
allowPackChange: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allowPackChange" className="cursor-pointer">
|
||||
Allow pack change
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowNameChange"
|
||||
checked={formData.uiControls?.allowNameChange}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
uiControls: {
|
||||
...formData.uiControls,
|
||||
allowNameChange: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allowNameChange" className="cursor-pointer">
|
||||
Allow name change
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowScoreEdit"
|
||||
checked={formData.uiControls?.allowScoreEdit}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
uiControls: {
|
||||
...formData.uiControls,
|
||||
allowScoreEdit: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allowScoreEdit" className="cursor-pointer">
|
||||
Allow score editing
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Game Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Game Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPlayers">Max Players</Label>
|
||||
<Input
|
||||
id="maxPlayers"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={formData.settings?.maxPlayers || 10}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
settings: {
|
||||
...formData.settings,
|
||||
maxPlayers: parseInt(e.target.value) || 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowSpectators"
|
||||
checked={formData.settings?.allowSpectators}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
settings: {
|
||||
...formData.settings,
|
||||
allowSpectators: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allowSpectators" className="cursor-pointer">
|
||||
Allow spectators
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="timerEnabled"
|
||||
checked={formData.settings?.timerEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
settings: {
|
||||
...formData.settings,
|
||||
timerEnabled: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="timerEnabled" className="cursor-pointer">
|
||||
Enable timer
|
||||
</Label>
|
||||
</div>
|
||||
{formData.settings?.timerEnabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timerDuration">Timer Duration (seconds)</Label>
|
||||
<Input
|
||||
id="timerDuration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={formData.settings?.timerDuration || 30}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
settings: {
|
||||
...formData.settings,
|
||||
timerDuration: parseInt(e.target.value) || 30,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Room'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
304
admin/src/components/PackImportDialog.tsx
Normal file
304
admin/src/components/PackImportDialog.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Upload, FileJson } from 'lucide-react'
|
||||
|
||||
interface PackImportDialogProps {
|
||||
open: boolean
|
||||
onImport: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PackImportDialog({ open, onImport, onClose }: PackImportDialogProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [jsonContent, setJsonContent] = useState('')
|
||||
const [packInfo, setPackInfo] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
isPublic: true,
|
||||
})
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [questionsCount, setQuestionsCount] = useState<number | null>(null)
|
||||
|
||||
const resetForm = () => {
|
||||
setJsonContent('')
|
||||
setPackInfo({ name: '', description: '', category: '', isPublic: true })
|
||||
setParseError(null)
|
||||
setQuestionsCount(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const parseJsonContent = (content: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
|
||||
// Check if it's an exported pack (has packInfo) or just questions array
|
||||
let questions: any[]
|
||||
if (parsed.packInfo && Array.isArray(parsed.questions)) {
|
||||
// Exported pack format
|
||||
questions = parsed.questions
|
||||
setPackInfo({
|
||||
name: parsed.packInfo.name || '',
|
||||
description: parsed.packInfo.description || '',
|
||||
category: parsed.packInfo.category || '',
|
||||
isPublic: parsed.packInfo.isPublic ?? true,
|
||||
})
|
||||
} else if (Array.isArray(parsed.questions)) {
|
||||
// Template format with questions array
|
||||
questions = parsed.questions
|
||||
} else if (Array.isArray(parsed)) {
|
||||
// Just an array of questions
|
||||
questions = parsed
|
||||
} else {
|
||||
throw new Error('Invalid format: expected questions array or exported pack format')
|
||||
}
|
||||
|
||||
// Validate questions structure
|
||||
const isValid = questions.every(
|
||||
(q: any) =>
|
||||
(q.question || q.text) &&
|
||||
Array.isArray(q.answers) &&
|
||||
q.answers.length > 0 &&
|
||||
q.answers.every(
|
||||
(a: any) => a.text && typeof a.points === 'number'
|
||||
)
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid question format. Each question must have "question" (or "text") and "answers" array with "text" and "points" fields.')
|
||||
}
|
||||
|
||||
setParseError(null)
|
||||
setQuestionsCount(questions.length)
|
||||
return questions
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON format'
|
||||
setParseError(errorMessage)
|
||||
setQuestionsCount(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string
|
||||
setJsonContent(content)
|
||||
parseJsonContent(content)
|
||||
}
|
||||
reader.onerror = () => {
|
||||
toast.error('Failed to read file')
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleJsonChange = (value: string) => {
|
||||
setJsonContent(value)
|
||||
if (value.trim()) {
|
||||
parseJsonContent(value)
|
||||
} else {
|
||||
setParseError(null)
|
||||
setQuestionsCount(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!packInfo.name.trim()) {
|
||||
toast.error('Pack name is required')
|
||||
return
|
||||
}
|
||||
if (!packInfo.description.trim()) {
|
||||
toast.error('Pack description is required')
|
||||
return
|
||||
}
|
||||
if (!packInfo.category.trim()) {
|
||||
toast.error('Pack category is required')
|
||||
return
|
||||
}
|
||||
if (!jsonContent.trim()) {
|
||||
toast.error('JSON content is required')
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = parseJsonContent(jsonContent)
|
||||
if (!parsed) {
|
||||
toast.error('Invalid JSON format')
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize questions to use 'question' field
|
||||
const normalizedQuestions = parsed.map((q: any) => ({
|
||||
question: q.question || q.text,
|
||||
answers: q.answers.map((a: any) => ({
|
||||
text: a.text,
|
||||
points: a.points,
|
||||
})),
|
||||
}))
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await packsApi.importPack({
|
||||
name: packInfo.name.trim(),
|
||||
description: packInfo.description.trim(),
|
||||
category: packInfo.category.trim(),
|
||||
isPublic: packInfo.isPublic,
|
||||
questions: normalizedQuestions,
|
||||
})
|
||||
toast.success(`Pack imported successfully with ${normalizedQuestions.length} questions`)
|
||||
handleClose()
|
||||
onImport()
|
||||
} catch (error) {
|
||||
const errorMessage = isPacksApiError(error) ? error.message : 'Failed to import pack'
|
||||
toast.error(errorMessage)
|
||||
console.error('Import error:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Pack from JSON</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a JSON file or paste JSON content to create a new pack with questions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* File Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label>Upload JSON File</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Select File
|
||||
</Button>
|
||||
{fileInputRef.current?.files?.[0] && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<FileJson className="h-4 w-4 inline mr-1" />
|
||||
{fileInputRef.current.files[0].name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Content */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jsonContent">Or Paste JSON Content</Label>
|
||||
<Textarea
|
||||
id="jsonContent"
|
||||
value={jsonContent}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
placeholder='[{"question": "...", "answers": [{"text": "...", "points": 100}]}]'
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{parseError && (
|
||||
<p className="text-sm text-red-500">{parseError}</p>
|
||||
)}
|
||||
{questionsCount !== null && !parseError && (
|
||||
<p className="text-sm text-green-600">
|
||||
Found {questionsCount} valid question{questionsCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="my-2" />
|
||||
|
||||
{/* Pack Info */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Pack Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={packInfo.name}
|
||||
onChange={(e) => setPackInfo((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Enter pack name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={packInfo.description}
|
||||
onChange={(e) => setPackInfo((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Enter pack description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Input
|
||||
id="category"
|
||||
value={packInfo.category}
|
||||
onChange={(e) => setPackInfo((prev) => ({ ...prev, category: e.target.value }))}
|
||||
placeholder="Enter category (e.g., general, family, etc.)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isPublic"
|
||||
checked={packInfo.isPublic}
|
||||
onCheckedChange={(checked) =>
|
||||
setPackInfo((prev) => ({ ...prev, isPublic: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="isPublic">Public</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={isLoading || !!parseError || questionsCount === null}
|
||||
>
|
||||
{isLoading ? 'Importing...' : 'Import Pack'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
413
admin/src/components/ThemeEditorDialog.tsx
Normal file
413
admin/src/components/ThemeEditorDialog.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
DEFAULT_THEME_COLORS,
|
||||
DEFAULT_THEME_SETTINGS,
|
||||
type ThemeColors,
|
||||
type ThemeSettings,
|
||||
type ThemePreview,
|
||||
} from '@/api/themes'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
interface ThemeEditorDialogProps {
|
||||
open: boolean
|
||||
theme: ThemePreview | null
|
||||
onSave: (data: {
|
||||
name: string
|
||||
isPublic: boolean
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
}) => void
|
||||
onClose: () => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
interface ColorFieldProps {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
description?: string
|
||||
}
|
||||
|
||||
function ColorField({ label, value, onChange, description }: ColorFieldProps) {
|
||||
const isGradient = value.includes('gradient') || value.includes('rgba')
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isGradient && (
|
||||
<input
|
||||
type="color"
|
||||
value={value.startsWith('#') ? value : '#ffffff'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-10 h-10 rounded border cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={label}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeEditorDialog({
|
||||
open,
|
||||
theme,
|
||||
onSave,
|
||||
onClose,
|
||||
isSaving = false,
|
||||
}: ThemeEditorDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
const [colors, setColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS)
|
||||
const [settings, setSettings] = useState<ThemeSettings>(DEFAULT_THEME_SETTINGS)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setName(theme.name)
|
||||
setIsPublic(theme.isPublic)
|
||||
setColors(theme.colors)
|
||||
setSettings(theme.settings)
|
||||
} else {
|
||||
setName('')
|
||||
setIsPublic(false)
|
||||
setColors(DEFAULT_THEME_COLORS)
|
||||
setSettings(DEFAULT_THEME_SETTINGS)
|
||||
}
|
||||
}, [theme, open])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
return
|
||||
}
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
isPublic,
|
||||
colors,
|
||||
settings,
|
||||
})
|
||||
}
|
||||
|
||||
const updateColor = (key: keyof ThemeColors, value: string) => {
|
||||
setColors((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const updateSetting = (key: keyof ThemeSettings, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{theme ? 'Edit Theme' : 'Create Theme'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{theme ? 'Update the theme configuration' : 'Create a new theme for the game'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Theme Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Custom Theme"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-8">
|
||||
<Checkbox
|
||||
id="isPublic"
|
||||
checked={isPublic}
|
||||
onCheckedChange={(checked) => setIsPublic(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="isPublic">Public theme (available to all users)</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs for Colors and Settings */}
|
||||
<Tabs defaultValue="colors">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="colors">Colors</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="colors" className="space-y-4 pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ColorField
|
||||
label="Background Primary"
|
||||
value={colors.bgPrimary}
|
||||
onChange={(v) => updateColor('bgPrimary', v)}
|
||||
description="Main background (gradient or solid)"
|
||||
/>
|
||||
<ColorField
|
||||
label="Background Overlay"
|
||||
value={colors.bgOverlay}
|
||||
onChange={(v) => updateColor('bgOverlay', v)}
|
||||
description="Overlay color (rgba recommended)"
|
||||
/>
|
||||
<ColorField
|
||||
label="Card Background"
|
||||
value={colors.bgCard}
|
||||
onChange={(v) => updateColor('bgCard', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Card Hover"
|
||||
value={colors.bgCardHover}
|
||||
onChange={(v) => updateColor('bgCardHover', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Text Primary"
|
||||
value={colors.textPrimary}
|
||||
onChange={(v) => updateColor('textPrimary', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Text Secondary"
|
||||
value={colors.textSecondary}
|
||||
onChange={(v) => updateColor('textSecondary', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Text Glow"
|
||||
value={colors.textGlow}
|
||||
onChange={(v) => updateColor('textGlow', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent Primary"
|
||||
value={colors.accentPrimary}
|
||||
onChange={(v) => updateColor('accentPrimary', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent Secondary"
|
||||
value={colors.accentSecondary}
|
||||
onChange={(v) => updateColor('accentSecondary', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent Success"
|
||||
value={colors.accentSuccess}
|
||||
onChange={(v) => updateColor('accentSuccess', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Border Color"
|
||||
value={colors.borderColor}
|
||||
onChange={(v) => updateColor('borderColor', v)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Border Glow"
|
||||
value={colors.borderGlow}
|
||||
onChange={(v) => updateColor('borderGlow', v)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Shadow Small</Label>
|
||||
<Input
|
||||
value={settings.shadowSm}
|
||||
onChange={(e) => updateSetting('shadowSm', e.target.value)}
|
||||
placeholder="0 1px 2px rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Shadow Medium</Label>
|
||||
<Input
|
||||
value={settings.shadowMd}
|
||||
onChange={(e) => updateSetting('shadowMd', e.target.value)}
|
||||
placeholder="0 4px 6px rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Shadow Large</Label>
|
||||
<Input
|
||||
value={settings.shadowLg}
|
||||
onChange={(e) => updateSetting('shadowLg', e.target.value)}
|
||||
placeholder="0 10px 15px rgba(0, 0, 0, 0.2)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Blur Amount</Label>
|
||||
<Input
|
||||
value={settings.blurAmount}
|
||||
onChange={(e) => updateSetting('blurAmount', e.target.value)}
|
||||
placeholder="10px"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Border Radius Small</Label>
|
||||
<Input
|
||||
value={settings.borderRadiusSm}
|
||||
onChange={(e) => updateSetting('borderRadiusSm', e.target.value)}
|
||||
placeholder="4px"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Border Radius Medium</Label>
|
||||
<Input
|
||||
value={settings.borderRadiusMd}
|
||||
onChange={(e) => updateSetting('borderRadiusMd', e.target.value)}
|
||||
placeholder="8px"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Border Radius Large</Label>
|
||||
<Input
|
||||
value={settings.borderRadiusLg}
|
||||
onChange={(e) => updateSetting('borderRadiusLg', e.target.value)}
|
||||
placeholder="12px"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Animation Speed</Label>
|
||||
<Input
|
||||
value={settings.animationSpeed}
|
||||
onChange={(e) => updateSetting('animationSpeed', e.target.value)}
|
||||
placeholder="0.3s"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="pt-4">
|
||||
<div
|
||||
className="rounded-lg p-6 min-h-[300px]"
|
||||
style={{
|
||||
background: colors.bgPrimary,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg p-4 mb-4"
|
||||
style={{
|
||||
background: colors.bgOverlay,
|
||||
backdropFilter: `blur(${settings.blurAmount})`,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-xl font-bold mb-2"
|
||||
style={{
|
||||
color: colors.accentPrimary,
|
||||
textShadow: `0 0 10px ${colors.textGlow}`,
|
||||
}}
|
||||
>
|
||||
Theme Preview
|
||||
</h3>
|
||||
<p style={{ color: colors.textPrimary }}>
|
||||
This is primary text
|
||||
</p>
|
||||
<p style={{ color: colors.textSecondary }}>
|
||||
This is secondary text
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className="flex-1 p-4 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: colors.bgCard,
|
||||
borderRadius: settings.borderRadiusMd,
|
||||
border: `1px solid ${colors.borderColor}`,
|
||||
boxShadow: settings.shadowMd,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="font-bold"
|
||||
style={{ color: colors.textPrimary }}
|
||||
>
|
||||
Card Example
|
||||
</div>
|
||||
<div style={{ color: colors.textSecondary }}>
|
||||
Card content here
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 p-4 rounded-lg"
|
||||
style={{
|
||||
background: colors.bgCardHover,
|
||||
borderRadius: settings.borderRadiusMd,
|
||||
border: `1px solid ${colors.borderGlow}`,
|
||||
boxShadow: `0 0 20px ${colors.borderGlow}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="font-bold"
|
||||
style={{ color: colors.accentPrimary }}
|
||||
>
|
||||
Active Card
|
||||
</div>
|
||||
<div style={{ color: colors.accentSecondary }}>
|
||||
Hover state
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg font-medium"
|
||||
style={{
|
||||
background: colors.accentPrimary,
|
||||
color: colors.bgPrimary.includes('gradient') ? '#000' : colors.bgPrimary,
|
||||
borderRadius: settings.borderRadiusSm,
|
||||
}}
|
||||
>
|
||||
Primary Button
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg font-medium"
|
||||
style={{
|
||||
background: colors.accentSuccess,
|
||||
color: '#000',
|
||||
borderRadius: settings.borderRadiusSm,
|
||||
}}
|
||||
>
|
||||
Success Button
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||
{isSaving ? 'Saving...' : theme ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,9 +6,11 @@ import {
|
|||
LayoutDashboard,
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
354
admin/src/pages/RoomsPage.tsx
Normal file
354
admin/src/pages/RoomsPage.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { roomsApi, type RoomDto } from '@/api/rooms'
|
||||
import type { AxiosError } from 'axios'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, Plus, Trash2, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'
|
||||
import { CreateAdminRoomDialog } from '@/components/CreateAdminRoomDialog'
|
||||
|
||||
export default function RoomsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'WAITING' | 'PLAYING' | 'FINISHED' | ''>('')
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [roomToDelete, setRoomToDelete] = useState<RoomDto | null>(null)
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch rooms
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'rooms', page, statusFilter],
|
||||
queryFn: () => roomsApi.getRooms({
|
||||
page,
|
||||
limit,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (roomId: string) => roomsApi.deleteRoom(roomId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'rooms'] })
|
||||
toast.success('Room deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setRoomToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to delete room')
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = (room: RoomDto) => {
|
||||
setRoomToDelete(room)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (roomToDelete?.id) {
|
||||
deleteMutation.mutate(roomToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'WAITING':
|
||||
return <Badge variant="secondary">Waiting</Badge>
|
||||
case 'PLAYING':
|
||||
return <Badge variant="default">Playing</Badge>
|
||||
case 'FINISHED':
|
||||
return <Badge variant="outline">Finished</Badge>
|
||||
default:
|
||||
return <Badge>{status}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRooms = data?.rooms.filter(room =>
|
||||
search === '' ||
|
||||
room.code.toLowerCase().includes(search.toLowerCase()) ||
|
||||
room.host.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
room.host.email?.toLowerCase().includes(search.toLowerCase())
|
||||
) || []
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Rooms Management</h1>
|
||||
<p className="text-muted-foreground">Error loading rooms</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-500">Failed to load rooms. Please try again later.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Rooms Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage game rooms
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Room
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by code, host name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant={statusFilter === '' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('')}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'WAITING' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('WAITING')}
|
||||
>
|
||||
Waiting
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'PLAYING' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('PLAYING')}
|
||||
>
|
||||
Playing
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'FINISHED' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('FINISHED')}
|
||||
>
|
||||
Finished
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rooms Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Rooms ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>
|
||||
Manage game rooms and their settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading rooms...</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>Theme</TableHead>
|
||||
<TableHead>Pack</TableHead>
|
||||
<TableHead>Players</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Active Period</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRooms.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
No rooms found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRooms.map((room) => (
|
||||
<TableRow key={room.id}>
|
||||
<TableCell className="font-mono font-medium">
|
||||
{room.code}
|
||||
{room.isAdminRoom && (
|
||||
<Badge variant="secondary" className="ml-2">Admin</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(room.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{room.host.name || 'No name'}</div>
|
||||
{room.host.email && (
|
||||
<div className="text-sm text-muted-foreground">{room.host.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{room.theme ? (
|
||||
<Badge variant="outline">{room.theme.name}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Default</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{room.questionPack ? (
|
||||
<span className="text-sm">{room.questionPack.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{room._count?.participants || 0} / {room.maxPlayers}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(room.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{room.activeFrom && room.activeTo ? (
|
||||
<div>
|
||||
<div>{new Date(room.activeFrom).toLocaleDateString()}</div>
|
||||
<div className="text-xs">to {new Date(room.activeTo).toLocaleDateString()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/join/${room.code}`, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(room)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} rooms
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Room Dialog */}
|
||||
{createDialogOpen && (
|
||||
<CreateAdminRoomDialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'rooms'] })
|
||||
setCreateDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Room</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the room "{roomToDelete?.code}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
381
admin/src/pages/ThemesPage.tsx
Normal file
381
admin/src/pages/ThemesPage.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
themesApi,
|
||||
isThemesApiError,
|
||||
DEFAULT_THEME_COLORS,
|
||||
DEFAULT_THEME_SETTINGS,
|
||||
type ThemePreview,
|
||||
type ThemeColors,
|
||||
type ThemeSettings,
|
||||
type CreateThemeDto,
|
||||
} from '@/api/themes'
|
||||
import { formatApiError } from '@/lib/error-utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Eye } from 'lucide-react'
|
||||
import { ThemeEditorDialog } from '@/components/ThemeEditorDialog'
|
||||
|
||||
export default function ThemesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showPrivate, setShowPrivate] = useState(true)
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false)
|
||||
const [editingTheme, setEditingTheme] = useState<ThemePreview | null>(null)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [themeToDelete, setThemeToDelete] = useState<ThemePreview | null>(null)
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch themes
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['themes', page, search, showPrivate],
|
||||
queryFn: () => themesApi.getThemes({
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
isPublic: showPrivate ? undefined : true,
|
||||
}),
|
||||
retry: (failureCount, error) => {
|
||||
if (isThemesApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (theme: CreateThemeDto) => themesApi.createTheme(theme),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['themes'] })
|
||||
toast.success('Theme created successfully')
|
||||
closeEditor()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isThemesApiError(error) ? error.message : 'Failed to create theme'
|
||||
toast.error(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, theme }: { id: string; theme: Partial<CreateThemeDto> }) =>
|
||||
themesApi.updateTheme(id, theme),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['themes'] })
|
||||
toast.success('Theme updated successfully')
|
||||
closeEditor()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isThemesApiError(error) ? error.message : 'Failed to update theme'
|
||||
toast.error(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (themeId: string) => themesApi.deleteTheme(themeId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['themes'] })
|
||||
toast.success('Theme deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setThemeToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isThemesApiError(error) ? error.message : 'Failed to delete theme'
|
||||
toast.error(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditingTheme(null)
|
||||
setIsEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEditEditor = (theme: ThemePreview) => {
|
||||
setEditingTheme(theme)
|
||||
setIsEditorOpen(true)
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
setIsEditorOpen(false)
|
||||
setEditingTheme(null)
|
||||
}
|
||||
|
||||
const handleSave = (formData: {
|
||||
name: string
|
||||
isPublic: boolean
|
||||
colors: ThemeColors
|
||||
settings: ThemeSettings
|
||||
}) => {
|
||||
if (editingTheme) {
|
||||
updateMutation.mutate({
|
||||
id: editingTheme.id,
|
||||
theme: formData,
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate(formData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (theme: ThemePreview) => {
|
||||
setThemeToDelete(theme)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (themeToDelete) {
|
||||
deleteMutation.mutate(themeToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMessage = isThemesApiError(error) ? error.message : formatApiError(error)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Themes Management</h1>
|
||||
<p className="text-muted-foreground">Error loading themes</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-500 font-medium">Failed to load themes</p>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['themes'] })}
|
||||
className="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Themes Management</h1>
|
||||
<p className="text-muted-foreground">Create and manage game themes</p>
|
||||
</div>
|
||||
<Button onClick={openCreateEditor}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Theme
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search themes..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showPrivate"
|
||||
checked={showPrivate}
|
||||
onCheckedChange={(checked) => setShowPrivate(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="showPrivate">Show private themes</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Themes Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Themes ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>Manage theme colors and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading themes...</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Preview</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Public</TableHead>
|
||||
<TableHead>Creator</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.themes || []).map((theme) => (
|
||||
<TableRow key={theme.id}>
|
||||
<TableCell>
|
||||
<div
|
||||
className="w-16 h-10 rounded-md border"
|
||||
style={{
|
||||
background: theme.colors.bgPrimary,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
color: theme.colors.accentPrimary,
|
||||
textShadow: `0 0 4px ${theme.colors.textGlow}`,
|
||||
}}
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{theme.name}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
theme.isPublic
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{theme.isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{theme.creator?.name || 'Unknown'}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(theme.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditEditor(theme)}
|
||||
title="Edit theme"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(theme)}
|
||||
title="Delete theme"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(page - 1) * limit + 1} to{' '}
|
||||
{Math.min(page * limit, data.total)} of {data.total} themes
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Theme Editor Dialog */}
|
||||
<ThemeEditorDialog
|
||||
open={isEditorOpen}
|
||||
theme={editingTheme}
|
||||
onSave={handleSave}
|
||||
onClose={closeEditor}
|
||||
isSaving={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Theme</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the theme "{themeToDelete?.name}"? This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ model Room {
|
|||
status RoomStatus @default(WAITING)
|
||||
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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
backend/src/admin/rooms/dto/create-admin-room.dto.ts
Normal file
94
backend/src/admin/rooms/dto/create-admin-room.dto.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class UiControlsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowThemeChange?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowPackChange?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowNameChange?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowScoreEdit?: boolean;
|
||||
}
|
||||
|
||||
class GameSettingsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
maxPlayers?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowSpectators?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
timerEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(300)
|
||||
timerDuration?: number;
|
||||
}
|
||||
|
||||
export class CreateAdminRoomDto {
|
||||
@IsString()
|
||||
hostId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
hostName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
activeFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
activeTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
themeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
questionPackId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UiControlsDto)
|
||||
uiControls?: UiControlsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => GameSettingsDto)
|
||||
settings?: GameSettingsDto;
|
||||
}
|
||||
|
||||
49
backend/src/admin/themes/admin-themes.controller.ts
Normal file
49
backend/src/admin/themes/admin-themes.controller.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AdminThemesService } from './admin-themes.service';
|
||||
import { ThemeFiltersDto } from './dto/theme-filters.dto';
|
||||
import { CreateThemeDto } from './dto/create-theme.dto';
|
||||
import { UpdateThemeDto } from './dto/update-theme.dto';
|
||||
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
||||
import { AdminGuard } from '../guards/admin.guard';
|
||||
|
||||
@Controller('api/admin/themes')
|
||||
@UseGuards(AdminAuthGuard, AdminGuard)
|
||||
export class AdminThemesController {
|
||||
constructor(private readonly adminThemesService: AdminThemesService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() filters: ThemeFiltersDto) {
|
||||
return this.adminThemesService.findAll(filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.adminThemesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() createThemeDto: CreateThemeDto, @Request() req) {
|
||||
return this.adminThemesService.create(createThemeDto, req.user.sub);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateThemeDto: UpdateThemeDto) {
|
||||
return this.adminThemesService.update(id, updateThemeDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.adminThemesService.remove(id);
|
||||
}
|
||||
}
|
||||
151
backend/src/admin/themes/admin-themes.service.ts
Normal file
151
backend/src/admin/themes/admin-themes.service.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ThemeFiltersDto } from './dto/theme-filters.dto';
|
||||
import { CreateThemeDto } from './dto/create-theme.dto';
|
||||
import { UpdateThemeDto } from './dto/update-theme.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminThemesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(filters: ThemeFiltersDto) {
|
||||
const { search, isPublic, page = 1, limit = 10 } = filters;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.name = { contains: search, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
if (isPublic !== undefined) {
|
||||
where.isPublic = isPublic;
|
||||
}
|
||||
|
||||
const [themes, total] = await Promise.all([
|
||||
this.prisma.theme.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isPublic: true,
|
||||
colors: true,
|
||||
settings: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.theme.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
themes,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const theme = await this.prisma.theme.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!theme) {
|
||||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
async create(createThemeDto: CreateThemeDto, createdBy: string) {
|
||||
return this.prisma.theme.create({
|
||||
data: {
|
||||
...createThemeDto,
|
||||
createdBy,
|
||||
},
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, updateThemeDto: UpdateThemeDto) {
|
||||
const theme = await this.prisma.theme.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!theme) {
|
||||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
return this.prisma.theme.update({
|
||||
where: { id },
|
||||
data: updateThemeDto,
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const theme = await this.prisma.theme.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!theme) {
|
||||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
await this.prisma.theme.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { message: 'Theme deleted successfully' };
|
||||
}
|
||||
|
||||
async findPublic() {
|
||||
return this.prisma.theme.findMany({
|
||||
where: { isPublic: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
colors: true,
|
||||
settings: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
91
backend/src/admin/themes/dto/create-theme.dto.ts
Normal file
91
backend/src/admin/themes/dto/create-theme.dto.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ThemeColorsDto {
|
||||
@IsString()
|
||||
bgPrimary: string; // Gradient or solid
|
||||
|
||||
@IsString()
|
||||
bgOverlay: string;
|
||||
|
||||
@IsString()
|
||||
bgCard: string;
|
||||
|
||||
@IsString()
|
||||
bgCardHover: string;
|
||||
|
||||
@IsString()
|
||||
textPrimary: string;
|
||||
|
||||
@IsString()
|
||||
textSecondary: string;
|
||||
|
||||
@IsString()
|
||||
textGlow: string;
|
||||
|
||||
@IsString()
|
||||
accentPrimary: string;
|
||||
|
||||
@IsString()
|
||||
accentSecondary: string;
|
||||
|
||||
@IsString()
|
||||
accentSuccess: string;
|
||||
|
||||
@IsString()
|
||||
borderColor: string;
|
||||
|
||||
@IsString()
|
||||
borderGlow: string;
|
||||
}
|
||||
|
||||
export class ThemeSettingsDto {
|
||||
@IsString()
|
||||
shadowSm: string;
|
||||
|
||||
@IsString()
|
||||
shadowMd: string;
|
||||
|
||||
@IsString()
|
||||
shadowLg: string;
|
||||
|
||||
@IsString()
|
||||
blurAmount: string;
|
||||
|
||||
@IsString()
|
||||
borderRadiusSm: string;
|
||||
|
||||
@IsString()
|
||||
borderRadiusMd: string;
|
||||
|
||||
@IsString()
|
||||
borderRadiusLg: string;
|
||||
|
||||
@IsString()
|
||||
animationSpeed: string;
|
||||
}
|
||||
|
||||
export class CreateThemeDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublic?: boolean;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ThemeColorsDto)
|
||||
colors: ThemeColorsDto;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ThemeSettingsDto)
|
||||
settings: ThemeSettingsDto;
|
||||
}
|
||||
25
backend/src/admin/themes/dto/theme-filters.dto.ts
Normal file
25
backend/src/admin/themes/dto/theme-filters.dto.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { IsOptional, IsString, IsBoolean, IsInt, Min } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
export class ThemeFiltersDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isPublic?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 10;
|
||||
}
|
||||
4
backend/src/admin/themes/dto/update-theme.dto.ts
Normal file
4
backend/src/admin/themes/dto/update-theme.dto.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateThemeDto } from './create-theme.dto';
|
||||
|
||||
export class UpdateThemeDto extends PartialType(CreateThemeDto) {}
|
||||
|
|
@ -10,6 +10,7 @@ import { GameModule } from './game/game.module';
|
|||
import { StatsModule } from './stats/stats.module';
|
||||
import { 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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
51
backend/src/themes/themes.controller.ts
Normal file
51
backend/src/themes/themes.controller.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('api/themes')
|
||||
export class ThemesController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async getPublicThemes() {
|
||||
return this.prisma.theme.findMany({
|
||||
where: { isPublic: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
colors: true,
|
||||
settings: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getTheme(@Param('id') id: string) {
|
||||
const theme = await this.prisma.theme.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isPublic: true,
|
||||
colors: true,
|
||||
settings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!theme) {
|
||||
throw new NotFoundException('Theme not found');
|
||||
}
|
||||
|
||||
if (!theme.isPublic) {
|
||||
throw new ForbiddenException('Theme is private');
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
9
backend/src/themes/themes.module.ts
Normal file
9
backend/src/themes/themes.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ThemesController } from './themes.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ThemesController],
|
||||
})
|
||||
export class ThemesModule {}
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
}
|
||||
|
||||
.title-number {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
166
src/index.css
166
src/index.css
|
|
@ -1,3 +1,70 @@
|
|||
/* CSS Variables for theming */
|
||||
:root {
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.8);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--accent-primary: #ffd700;
|
||||
--accent-secondary: #ffed4e;
|
||||
--accent-success: #4ade80;
|
||||
--bg-primary: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%);
|
||||
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||
--bg-card: rgba(255, 255, 255, 0.1);
|
||||
--bg-card-hover: rgba(255, 255, 255, 0.2);
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
--border-glow: rgba(255, 215, 0, 0.3);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.2);
|
||||
--blur-amount: 10px;
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
--animation-speed: 0.3s;
|
||||
}
|
||||
|
||||
/* Theme variations */
|
||||
[data-theme="new-year"] {
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.8);
|
||||
--accent-primary: #ffd700;
|
||||
--accent-secondary: #ffed4e;
|
||||
--bg-primary: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%);
|
||||
--border-glow: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="family"] {
|
||||
--text-primary: #2d3748;
|
||||
--text-secondary: rgba(45, 55, 72, 0.8);
|
||||
--accent-primary: #4299e1;
|
||||
--accent-secondary: #63b3ed;
|
||||
--bg-primary: linear-gradient(135deg, #ebf4ff 0%, #c3dafe 100%);
|
||||
--bg-card: rgba(255, 255, 255, 0.7);
|
||||
--bg-card-hover: rgba(255, 255, 255, 0.9);
|
||||
--border-color: rgba(66, 153, 225, 0.3);
|
||||
--border-glow: rgba(66, 153, 225, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="party"] {
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.9);
|
||||
--accent-primary: #f56565;
|
||||
--accent-secondary: #fc8181;
|
||||
--bg-primary: linear-gradient(135deg, #9f7aea 0%, #ed64a6 100%);
|
||||
--border-glow: rgba(237, 100, 166, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: rgba(226, 232, 240, 0.8);
|
||||
--accent-primary: #48bb78;
|
||||
--accent-secondary: #68d391;
|
||||
--bg-primary: linear-gradient(135deg, #000000 0%, #1a202c 100%);
|
||||
--bg-card: rgba(45, 55, 72, 0.8);
|
||||
--bg-card-hover: rgba(45, 55, 72, 0.95);
|
||||
--border-color: rgba(74, 85, 104, 0.5);
|
||||
--border-glow: rgba(72, 187, 120, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Reference in a new issue