From 96577926c82e7d7fb6c21b3196ea450760f682e5 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 10 Jan 2026 00:36:49 +0300 Subject: [PATCH] admin --- admin/src/App.tsx | 4 + admin/src/api/rooms.ts | 192 +++++++ admin/src/api/themes.ts | 238 +++++++++ .../src/components/CreateAdminRoomDialog.tsx | 483 ++++++++++++++++++ admin/src/components/PackImportDialog.tsx | 304 +++++++++++ admin/src/components/ThemeEditorDialog.tsx | 413 +++++++++++++++ admin/src/components/layout/Layout.tsx | 6 +- admin/src/pages/PacksPage.tsx | 79 ++- admin/src/pages/RoomsPage.tsx | 354 +++++++++++++ admin/src/pages/ThemesPage.tsx | 381 ++++++++++++++ backend/prisma/schema.prisma | 12 +- backend/src/admin/admin.module.ts | 8 + .../src/admin/packs/admin-packs.controller.ts | 55 +- .../src/admin/rooms/admin-rooms.controller.ts | 8 + .../src/admin/rooms/admin-rooms.service.ts | 100 +++- .../admin/rooms/dto/create-admin-room.dto.ts | 94 ++++ .../admin/themes/admin-themes.controller.ts | 49 ++ .../src/admin/themes/admin-themes.service.ts | 151 ++++++ .../src/admin/themes/dto/create-theme.dto.ts | 91 ++++ .../src/admin/themes/dto/theme-filters.dto.ts | 25 + .../src/admin/themes/dto/update-theme.dto.ts | 4 + backend/src/app.module.ts | 2 + backend/src/game/game.gateway.ts | 51 ++ backend/src/rooms/rooms.controller.ts | 4 +- backend/src/rooms/rooms.service.ts | 59 ++- backend/src/themes/themes.controller.ts | 51 ++ backend/src/themes/themes.module.ts | 9 + src/App.css | 6 +- src/components/Answer.css | 2 +- src/components/Game.css | 16 +- src/components/GameManagementModal.css | 24 +- src/components/GameManagementModal.jsx | 73 ++- src/components/HostAdminPanel.css | 12 +- src/components/NameInputModal.css | 10 +- src/components/Players.css | 8 +- src/components/PlayersModal.css | 10 +- src/components/QRModal.css | 4 +- src/components/Question.css | 6 +- src/components/QuestionsModal.css | 56 +- src/components/Snowflakes.jsx | 67 ++- src/components/VoiceSettings.css | 4 +- src/context/ThemeContext.jsx | 105 +++- src/hooks/useRoom.js | 14 +- src/index.css | 166 +++++- src/pages/CreateRoom.jsx | 14 + src/pages/GamePage.css | 2 +- src/services/api.js | 4 +- 47 files changed, 3640 insertions(+), 190 deletions(-) create mode 100644 admin/src/api/rooms.ts create mode 100644 admin/src/api/themes.ts create mode 100644 admin/src/components/CreateAdminRoomDialog.tsx create mode 100644 admin/src/components/PackImportDialog.tsx create mode 100644 admin/src/components/ThemeEditorDialog.tsx create mode 100644 admin/src/pages/RoomsPage.tsx create mode 100644 admin/src/pages/ThemesPage.tsx create mode 100644 backend/src/admin/rooms/dto/create-admin-room.dto.ts create mode 100644 backend/src/admin/themes/admin-themes.controller.ts create mode 100644 backend/src/admin/themes/admin-themes.service.ts create mode 100644 backend/src/admin/themes/dto/create-theme.dto.ts create mode 100644 backend/src/admin/themes/dto/theme-filters.dto.ts create mode 100644 backend/src/admin/themes/dto/update-theme.dto.ts create mode 100644 backend/src/themes/themes.controller.ts create mode 100644 backend/src/themes/themes.module.ts diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 290e9bb..00bc34e 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -4,6 +4,8 @@ import LoginPage from '@/pages/LoginPage' import DashboardPage from '@/pages/DashboardPage' import PacksPage from '@/pages/PacksPage' import UsersPage from '@/pages/UsersPage' +import ThemesPage from '@/pages/ThemesPage' +import RoomsPage from '@/pages/RoomsPage' import Layout from '@/components/layout/Layout' import { TokenRefreshProvider } from '@/components/TokenRefreshProvider' @@ -27,6 +29,8 @@ function App() { } /> } /> } /> + } /> + } /> ) : ( diff --git a/admin/src/api/rooms.ts b/admin/src/api/rooms.ts new file mode 100644 index 0000000..fe14752 --- /dev/null +++ b/admin/src/api/rooms.ts @@ -0,0 +1,192 @@ +import { adminApiClient } from './client' +import type { AxiosError } from 'axios' + +export interface RoomDto { + id: string + code: string + status: 'WAITING' | 'PLAYING' | 'FINISHED' + hostId: string + createdAt: string + expiresAt?: string + isAdminRoom: boolean + customCode?: string + activeFrom?: string + activeTo?: string + themeId?: string + questionPackId?: string + uiControls?: { + allowThemeChange?: boolean + allowPackChange?: boolean + allowNameChange?: boolean + allowScoreEdit?: boolean + } + maxPlayers: number + allowSpectators: boolean + timerEnabled: boolean + timerDuration: number + host: { + id: string + name?: string + email?: string + } + theme?: { + id: string + name: string + isPublic: boolean + } + questionPack?: { + id: string + name: string + } + _count?: { + participants: number + } + startedAt?: string + finishedAt?: string +} + +export interface CreateAdminRoomDto { + hostId: string + hostName?: string + customCode?: string + activeFrom?: string + activeTo?: string + themeId?: string + questionPackId?: string + uiControls?: { + allowThemeChange?: boolean + allowPackChange?: boolean + allowNameChange?: boolean + allowScoreEdit?: boolean + } + settings?: { + maxPlayers?: number + allowSpectators?: boolean + timerEnabled?: boolean + timerDuration?: number + } +} + +export interface RoomsResponse { + rooms: RoomDto[] + total: number + page: number + limit: number + totalPages: number +} + +export interface RoomsApiError { + message: string + statusCode?: number + originalError?: unknown + name: 'RoomsApiError' +} + +export function createRoomsApiError( + message: string, + statusCode?: number, + originalError?: unknown +): RoomsApiError { + return { + message, + statusCode, + originalError, + name: 'RoomsApiError', + } +} + +export function isRoomsApiError(error: unknown): error is RoomsApiError { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'RoomsApiError' + ) +} + +export const roomsApi = { + getRooms: async (params?: { + page?: number + limit?: number + status?: 'WAITING' | 'PLAYING' | 'FINISHED' + dateFrom?: string + dateTo?: string + }): Promise => { + try { + const response = await adminApiClient.get('/api/admin/rooms', { + params: { + page: params?.page || 1, + limit: params?.limit || 20, + status: params?.status, + dateFrom: params?.dateFrom, + dateTo: params?.dateTo, + }, + }) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + throw createRoomsApiError( + axiosError.response?.data?.message || 'Failed to load rooms', + axiosError.response?.status, + error + ) + } + }, + + getRoom: async (roomId: string): Promise => { + try { + const response = await adminApiClient.get(`/api/admin/rooms/${roomId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + if (axiosError.response?.status === 404) { + throw createRoomsApiError( + `Room not found: The room with ID "${roomId}" does not exist.`, + axiosError.response.status, + error + ) + } + throw createRoomsApiError( + axiosError.response?.data?.message || `Failed to load room ${roomId}`, + axiosError.response?.status, + error + ) + } + }, + + createAdminRoom: async (room: CreateAdminRoomDto): Promise => { + try { + const response = await adminApiClient.post('/api/admin/rooms', room) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + throw createRoomsApiError( + axiosError.response?.data?.message || 'Failed to create room', + axiosError.response?.status, + error + ) + } + }, + + deleteRoom: async (roomId: string): Promise<{ message: string }> => { + try { + const response = await adminApiClient.delete(`/api/admin/rooms/${roomId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + if (axiosError.response?.status === 404) { + throw createRoomsApiError( + `Room not found: The room with ID "${roomId}" does not exist.`, + axiosError.response.status, + error + ) + } + throw createRoomsApiError( + axiosError.response?.data?.message || `Failed to delete room ${roomId}`, + axiosError.response?.status, + error + ) + } + }, +} + diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts new file mode 100644 index 0000000..aab4eb6 --- /dev/null +++ b/admin/src/api/themes.ts @@ -0,0 +1,238 @@ +import { adminApiClient } from './client' +import type { AxiosError } from 'axios' + +export interface ThemeColors { + bgPrimary: string + bgOverlay: string + bgCard: string + bgCardHover: string + textPrimary: string + textSecondary: string + textGlow: string + accentPrimary: string + accentSecondary: string + accentSuccess: string + borderColor: string + borderGlow: string +} + +export interface ThemeSettings { + shadowSm: string + shadowMd: string + shadowLg: string + blurAmount: string + borderRadiusSm: string + borderRadiusMd: string + borderRadiusLg: string + animationSpeed: string +} + +export interface Theme { + id: string + name: string + isPublic: boolean + colors: ThemeColors + settings: ThemeSettings + createdAt: string + updatedAt: string + creator?: { + id: string + name: string | null + email: string | null + } +} + +export interface ThemePreview { + id: string + name: string + isPublic: boolean + colors: ThemeColors + settings: ThemeSettings + createdAt: string + creator?: { + id: string + name: string | null + } +} + +export interface CreateThemeDto { + name: string + isPublic?: boolean + colors: ThemeColors + settings: ThemeSettings +} + +export interface UpdateThemeDto { + name?: string + isPublic?: boolean + colors?: ThemeColors + settings?: ThemeSettings +} + +export interface PaginatedThemesResponse { + themes: ThemePreview[] + total: number + page: number + limit: number + totalPages: number +} + +export interface ThemesApiError { + message: string + statusCode?: number + originalError?: unknown + name: 'ThemesApiError' +} + +export function createThemesApiError( + message: string, + statusCode?: number, + originalError?: unknown +): ThemesApiError { + return { + message, + statusCode, + originalError, + name: 'ThemesApiError', + } +} + +export function isThemesApiError(error: unknown): error is ThemesApiError { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'ThemesApiError' + ) +} + +export const themesApi = { + getThemes: async (params?: { + page?: number + limit?: number + search?: string + isPublic?: boolean + }): Promise => { + try { + const response = await adminApiClient.get('/api/admin/themes', { + params: { + page: params?.page || 1, + limit: params?.limit || 20, + search: params?.search, + isPublic: params?.isPublic, + }, + }) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + throw createThemesApiError( + axiosError.response?.data?.message || 'Failed to load themes', + axiosError.response?.status, + error + ) + } + }, + + getTheme: async (themeId: string): Promise => { + try { + const response = await adminApiClient.get(`/api/admin/themes/${themeId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + if (axiosError.response?.status === 404) { + throw createThemesApiError( + `Theme not found: The theme with ID "${themeId}" does not exist.`, + axiosError.response.status, + error + ) + } + throw createThemesApiError( + axiosError.response?.data?.message || `Failed to load theme ${themeId}`, + axiosError.response?.status, + error + ) + } + }, + + createTheme: async (theme: CreateThemeDto): Promise => { + try { + const response = await adminApiClient.post('/api/admin/themes', theme) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + throw createThemesApiError( + axiosError.response?.data?.message || 'Failed to create theme', + axiosError.response?.status, + error + ) + } + }, + + updateTheme: async (themeId: string, theme: UpdateThemeDto): Promise => { + try { + const response = await adminApiClient.patch(`/api/admin/themes/${themeId}`, theme) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + if (axiosError.response?.status === 404) { + throw createThemesApiError( + `Theme not found: The theme with ID "${themeId}" does not exist.`, + axiosError.response.status, + error + ) + } + throw createThemesApiError( + axiosError.response?.data?.message || `Failed to update theme ${themeId}`, + axiosError.response?.status, + error + ) + } + }, + + deleteTheme: async (themeId: string): Promise<{ message: string }> => { + try { + const response = await adminApiClient.delete(`/api/admin/themes/${themeId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ message?: string }> + if (axiosError.response?.status === 404) { + throw createThemesApiError( + `Theme not found: The theme with ID "${themeId}" does not exist.`, + axiosError.response.status, + error + ) + } + throw createThemesApiError( + axiosError.response?.data?.message || `Failed to delete theme ${themeId}`, + axiosError.response?.status, + error + ) + } + }, +} + +export const DEFAULT_THEME_COLORS: ThemeColors = { + bgPrimary: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + bgOverlay: 'rgba(0, 0, 0, 0.7)', + bgCard: 'rgba(255, 255, 255, 0.1)', + bgCardHover: 'rgba(255, 255, 255, 0.2)', + textPrimary: '#ffffff', + textSecondary: 'rgba(255, 255, 255, 0.8)', + textGlow: '#ffd700', + accentPrimary: '#ffd700', + accentSecondary: '#ffed4e', + accentSuccess: '#4ade80', + borderColor: 'rgba(255, 255, 255, 0.2)', + borderGlow: 'rgba(255, 215, 0, 0.3)', +} + +export const DEFAULT_THEME_SETTINGS: ThemeSettings = { + shadowSm: '0 1px 2px rgba(0, 0, 0, 0.1)', + shadowMd: '0 4px 6px rgba(0, 0, 0, 0.1)', + shadowLg: '0 10px 15px rgba(0, 0, 0, 0.2)', + blurAmount: '10px', + borderRadiusSm: '4px', + borderRadiusMd: '8px', + borderRadiusLg: '12px', + animationSpeed: '0.3s', +} diff --git a/admin/src/components/CreateAdminRoomDialog.tsx b/admin/src/components/CreateAdminRoomDialog.tsx new file mode 100644 index 0000000..38bfcc4 --- /dev/null +++ b/admin/src/components/CreateAdminRoomDialog.tsx @@ -0,0 +1,483 @@ +import { useState } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' +import { roomsApi, type CreateAdminRoomDto } from '@/api/rooms' +import { usersApi } from '@/api/users' +import { themesApi } from '@/api/themes' +import { packsApi } from '@/api/packs' +import type { AxiosError } from 'axios' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +interface CreateAdminRoomDialogProps { + open: boolean + onClose: () => void + onSuccess: () => void +} + +export function CreateAdminRoomDialog({ + open, + onClose, + onSuccess, +}: CreateAdminRoomDialogProps) { + const [formData, setFormData] = useState({ + hostId: '', + hostName: 'Ведущий', + customCode: '', + activeFrom: undefined, + activeTo: undefined, + themeId: undefined, + questionPackId: undefined, + uiControls: { + allowThemeChange: true, + allowPackChange: true, + allowNameChange: true, + allowScoreEdit: true, + }, + settings: { + maxPlayers: 10, + allowSpectators: true, + timerEnabled: false, + timerDuration: 30, + }, + }) + + const [showAdvanced, setShowAdvanced] = useState(false) + + // Fetch data + const { data: usersData } = useQuery({ + queryKey: ['users', 1], + queryFn: () => usersApi.getUsers({ page: 1, limit: 100 }), + }) + + const { data: themesData } = useQuery({ + queryKey: ['themes'], + queryFn: () => themesApi.getThemes({ page: 1, limit: 100 }), + }) + + const { data: packsData } = useQuery({ + queryKey: ['packs', 1], + queryFn: () => packsApi.getPacks({ page: 1, limit: 100 }), + }) + + // Create mutation + const createMutation = useMutation({ + mutationFn: (data: CreateAdminRoomDto) => roomsApi.createAdminRoom(data), + onSuccess: () => { + toast.success('Room created successfully') + onSuccess() + // Reset form + setFormData({ + hostId: '', + hostName: 'Ведущий', + customCode: '', + activeFrom: undefined, + activeTo: undefined, + themeId: undefined, + questionPackId: undefined, + uiControls: { + allowThemeChange: true, + allowPackChange: true, + allowNameChange: true, + allowScoreEdit: true, + }, + settings: { + maxPlayers: 10, + allowSpectators: true, + timerEnabled: false, + timerDuration: 30, + }, + }) + }, + onError: (error: unknown) => { + const axiosError = error as AxiosError<{ message?: string }> + toast.error(axiosError.response?.data?.message || 'Failed to create room') + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.hostId) { + toast.error('Please select a host') + return + } + + // Prepare data - remove undefined values + const submitData: CreateAdminRoomDto = { + hostId: formData.hostId, + hostName: formData.hostName || undefined, + customCode: formData.customCode || undefined, + activeFrom: formData.activeFrom || undefined, + activeTo: formData.activeTo || undefined, + themeId: formData.themeId || undefined, + questionPackId: formData.questionPackId || undefined, + uiControls: formData.uiControls, + settings: formData.settings, + } + + createMutation.mutate(submitData) + } + + const formatDateTimeLocal = (dateStr?: string) => { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` + } + + const parseDateTimeLocal = (value: string) => { + if (!value) return undefined + return new Date(value).toISOString() + } + + return ( + + + + Create Admin Room + + Create a new game room with custom settings + + + +
+
+ {/* Host Selection */} +
+ + +
+ + {/* Host Name */} +
+ + setFormData({ ...formData, hostName: e.target.value })} + placeholder="Ведущий" + /> +
+ + {/* Custom Code */} +
+ + setFormData({ ...formData, customCode: e.target.value })} + placeholder="Leave empty for random code" + maxLength={6} + /> +

+ Leave empty to generate a random 6-character code +

+
+ + {/* Time Period */} + + + Active Period (optional) + + Set when the room should be available + + + +
+ + + setFormData({ + ...formData, + activeFrom: parseDateTimeLocal(e.target.value), + }) + } + /> +
+
+ + + setFormData({ + ...formData, + activeTo: parseDateTimeLocal(e.target.value), + }) + } + /> +
+
+
+ + {/* Theme Selection */} +
+ + +
+ + {/* Question Pack */} +
+ + +
+ + {/* UI Controls */} + + + UI Controls + + Control which features are available to players and host + + + +
+ + setFormData({ + ...formData, + uiControls: { + ...formData.uiControls, + allowThemeChange: checked as boolean, + }, + }) + } + /> + +
+
+ + setFormData({ + ...formData, + uiControls: { + ...formData.uiControls, + allowPackChange: checked as boolean, + }, + }) + } + /> + +
+
+ + setFormData({ + ...formData, + uiControls: { + ...formData.uiControls, + allowNameChange: checked as boolean, + }, + }) + } + /> + +
+
+ + setFormData({ + ...formData, + uiControls: { + ...formData.uiControls, + allowScoreEdit: checked as boolean, + }, + }) + } + /> + +
+
+
+ + {/* Game Settings */} + + + Game Settings + + +
+ + + setFormData({ + ...formData, + settings: { + ...formData.settings, + maxPlayers: parseInt(e.target.value) || 10, + }, + }) + } + /> +
+
+ + setFormData({ + ...formData, + settings: { + ...formData.settings, + allowSpectators: checked as boolean, + }, + }) + } + /> + +
+
+ + setFormData({ + ...formData, + settings: { + ...formData.settings, + timerEnabled: checked as boolean, + }, + }) + } + /> + +
+ {formData.settings?.timerEnabled && ( +
+ + + setFormData({ + ...formData, + settings: { + ...formData.settings, + timerDuration: parseInt(e.target.value) || 30, + }, + }) + } + /> +
+ )} +
+
+
+ + + + + +
+
+
+ ) +} + diff --git a/admin/src/components/PackImportDialog.tsx b/admin/src/components/PackImportDialog.tsx new file mode 100644 index 0000000..2a042c9 --- /dev/null +++ b/admin/src/components/PackImportDialog.tsx @@ -0,0 +1,304 @@ +import { useState, useRef } from 'react' +import { toast } from 'sonner' +import { packsApi, isPacksApiError } from '@/api/packs' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Upload, FileJson } from 'lucide-react' + +interface PackImportDialogProps { + open: boolean + onImport: () => void + onClose: () => void +} + +export function PackImportDialog({ open, onImport, onClose }: PackImportDialogProps) { + const fileInputRef = useRef(null) + const [isLoading, setIsLoading] = useState(false) + const [jsonContent, setJsonContent] = useState('') + const [packInfo, setPackInfo] = useState({ + name: '', + description: '', + category: '', + isPublic: true, + }) + const [parseError, setParseError] = useState(null) + const [questionsCount, setQuestionsCount] = useState(null) + + const resetForm = () => { + setJsonContent('') + setPackInfo({ name: '', description: '', category: '', isPublic: true }) + setParseError(null) + setQuestionsCount(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const handleClose = () => { + resetForm() + onClose() + } + + const parseJsonContent = (content: string) => { + try { + const parsed = JSON.parse(content) + + // Check if it's an exported pack (has packInfo) or just questions array + let questions: any[] + if (parsed.packInfo && Array.isArray(parsed.questions)) { + // Exported pack format + questions = parsed.questions + setPackInfo({ + name: parsed.packInfo.name || '', + description: parsed.packInfo.description || '', + category: parsed.packInfo.category || '', + isPublic: parsed.packInfo.isPublic ?? true, + }) + } else if (Array.isArray(parsed.questions)) { + // Template format with questions array + questions = parsed.questions + } else if (Array.isArray(parsed)) { + // Just an array of questions + questions = parsed + } else { + throw new Error('Invalid format: expected questions array or exported pack format') + } + + // Validate questions structure + const isValid = questions.every( + (q: any) => + (q.question || q.text) && + Array.isArray(q.answers) && + q.answers.length > 0 && + q.answers.every( + (a: any) => a.text && typeof a.points === 'number' + ) + ) + + if (!isValid) { + throw new Error('Invalid question format. Each question must have "question" (or "text") and "answers" array with "text" and "points" fields.') + } + + setParseError(null) + setQuestionsCount(questions.length) + return questions + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Invalid JSON format' + setParseError(errorMessage) + setQuestionsCount(null) + return null + } + } + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (event) => { + const content = event.target?.result as string + setJsonContent(content) + parseJsonContent(content) + } + reader.onerror = () => { + toast.error('Failed to read file') + } + reader.readAsText(file) + } + + const handleJsonChange = (value: string) => { + setJsonContent(value) + if (value.trim()) { + parseJsonContent(value) + } else { + setParseError(null) + setQuestionsCount(null) + } + } + + const handleImport = async () => { + if (!packInfo.name.trim()) { + toast.error('Pack name is required') + return + } + if (!packInfo.description.trim()) { + toast.error('Pack description is required') + return + } + if (!packInfo.category.trim()) { + toast.error('Pack category is required') + return + } + if (!jsonContent.trim()) { + toast.error('JSON content is required') + return + } + + const parsed = parseJsonContent(jsonContent) + if (!parsed) { + toast.error('Invalid JSON format') + return + } + + // Normalize questions to use 'question' field + const normalizedQuestions = parsed.map((q: any) => ({ + question: q.question || q.text, + answers: q.answers.map((a: any) => ({ + text: a.text, + points: a.points, + })), + })) + + setIsLoading(true) + try { + await packsApi.importPack({ + name: packInfo.name.trim(), + description: packInfo.description.trim(), + category: packInfo.category.trim(), + isPublic: packInfo.isPublic, + questions: normalizedQuestions, + }) + toast.success(`Pack imported successfully with ${normalizedQuestions.length} questions`) + handleClose() + onImport() + } catch (error) { + const errorMessage = isPacksApiError(error) ? error.message : 'Failed to import pack' + toast.error(errorMessage) + console.error('Import error:', error) + } finally { + setIsLoading(false) + } + } + + return ( + !isOpen && handleClose()}> + + + Import Pack from JSON + + Upload a JSON file or paste JSON content to create a new pack with questions. + + + +
+ {/* File Upload */} +
+ +
+ + + {fileInputRef.current?.files?.[0] && ( + + + {fileInputRef.current.files[0].name} + + )} +
+
+ + {/* JSON Content */} +
+ +