admin
This commit is contained in:
parent
7604fe2004
commit
5983bc5bed
3 changed files with 419 additions and 13 deletions
249
admin/src/components/GameQuestionEditorDialog.tsx
Normal file
249
admin/src/components/GameQuestionEditorDialog.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { Question, Answer } from '@/types/models'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface GameQuestionEditorDialogProps {
|
||||||
|
open: boolean
|
||||||
|
question: Question | null
|
||||||
|
onSave: (question: Question) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameQuestionEditorDialog({
|
||||||
|
open,
|
||||||
|
question,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: GameQuestionEditorDialogProps) {
|
||||||
|
const [questionText, setQuestionText] = useState('')
|
||||||
|
const [answers, setAnswers] = useState<Answer[]>([
|
||||||
|
{ text: '', points: 100 },
|
||||||
|
{ text: '', points: 80 },
|
||||||
|
{ text: '', points: 60 },
|
||||||
|
{ text: '', points: 40 },
|
||||||
|
{ text: '', points: 20 },
|
||||||
|
{ text: '', points: 10 },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Инициализация формы при открытии диалога
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (question) {
|
||||||
|
setQuestionText(question.question || '')
|
||||||
|
setAnswers(
|
||||||
|
question.answers && question.answers.length > 0
|
||||||
|
? [...question.answers]
|
||||||
|
: [
|
||||||
|
{ text: '', points: 100 },
|
||||||
|
{ text: '', points: 80 },
|
||||||
|
{ text: '', points: 60 },
|
||||||
|
{ text: '', points: 40 },
|
||||||
|
{ text: '', points: 20 },
|
||||||
|
{ text: '', points: 10 },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setQuestionText('')
|
||||||
|
setAnswers([
|
||||||
|
{ text: '', points: 100 },
|
||||||
|
{ text: '', points: 80 },
|
||||||
|
{ text: '', points: 60 },
|
||||||
|
{ text: '', points: 40 },
|
||||||
|
{ text: '', points: 20 },
|
||||||
|
{ text: '', points: 10 },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, question])
|
||||||
|
|
||||||
|
const handleAnswerChange = (index: number, field: 'text' | 'points', value: string | number) => {
|
||||||
|
const updatedAnswers = [...answers]
|
||||||
|
if (field === 'text') {
|
||||||
|
updatedAnswers[index].text = value as string
|
||||||
|
} else {
|
||||||
|
updatedAnswers[index].points = typeof value === 'number' ? value : parseInt(value as string) || 0
|
||||||
|
}
|
||||||
|
setAnswers(updatedAnswers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddAnswer = () => {
|
||||||
|
const minPoints = Math.min(...answers.map(a => a.points))
|
||||||
|
setAnswers([...answers, { text: '', points: Math.max(0, minPoints - 10) }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAnswer = (index: number) => {
|
||||||
|
if (answers.length > 1) {
|
||||||
|
setAnswers(answers.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Валидация
|
||||||
|
if (!questionText.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validAnswers = answers
|
||||||
|
.filter(a => a.text.trim())
|
||||||
|
.map(a => ({
|
||||||
|
text: a.text.trim(),
|
||||||
|
points: a.points,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (validAnswers.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionData: Question = {
|
||||||
|
question: questionText.trim(),
|
||||||
|
answers: validAnswers,
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(questionData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
questionText.trim().length > 0 &&
|
||||||
|
answers.filter(a => a.text.trim()).length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{question ? 'Edit Question' : 'Create New Question'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{question
|
||||||
|
? 'Update the question information'
|
||||||
|
: 'Add a new question to the pack'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="question-text">
|
||||||
|
Question Text <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="question-text"
|
||||||
|
value={questionText}
|
||||||
|
onChange={(e) => setQuestionText(e.target.value)}
|
||||||
|
placeholder="Enter the question text"
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>
|
||||||
|
Answers <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddAnswer}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Answer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{answers.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No answers yet. Add at least one answer.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{answers.map((answer, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Answer {index + 1}
|
||||||
|
</Label>
|
||||||
|
{answers.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveAnswer(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_120px] gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`answer-text-${index}`}>Text</Label>
|
||||||
|
<Input
|
||||||
|
id={`answer-text-${index}`}
|
||||||
|
value={answer.text}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAnswerChange(index, 'text', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Answer text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`answer-points-${index}`}>
|
||||||
|
Points
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`answer-points-${index}`}
|
||||||
|
type="number"
|
||||||
|
value={answer.points}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAnswerChange(
|
||||||
|
index,
|
||||||
|
'points',
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleSave} disabled={!isValid}>
|
||||||
|
{question ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
148
admin/src/components/GameQuestionsManager.tsx
Normal file
148
admin/src/components/GameQuestionsManager.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { Question } from '@/types/models'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Edit, Trash2, FileText } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
interface GameQuestionsManagerProps {
|
||||||
|
questions: Question[]
|
||||||
|
onChange: (questions: Question[]) => void
|
||||||
|
onEdit: (question: Question, index: number) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameQuestionsManager({
|
||||||
|
questions,
|
||||||
|
onChange,
|
||||||
|
onEdit,
|
||||||
|
disabled = false,
|
||||||
|
}: GameQuestionsManagerProps) {
|
||||||
|
const [questionToDelete, setQuestionToDelete] = useState<{
|
||||||
|
question: Question
|
||||||
|
index: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const handleDelete = (question: Question, index: number) => {
|
||||||
|
setQuestionToDelete({ question, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (questionToDelete) {
|
||||||
|
const newQuestions = questions.filter(
|
||||||
|
(_, i) => i !== questionToDelete.index,
|
||||||
|
)
|
||||||
|
onChange(newQuestions)
|
||||||
|
setQuestionToDelete(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Questions</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{questions.length} question{questions.length !== 1 ? 's' : ''} in
|
||||||
|
this pack
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FileText className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No questions yet. Add your first question to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{questions.map((question, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{question.question}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{question.answers.length} answer
|
||||||
|
{question.answers.length !== 1 ? 's' : ''} (points:{' '}
|
||||||
|
{question.answers
|
||||||
|
.map((a) => a.points)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.join(', ')})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(question, index)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(question, index)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={questionToDelete !== null}
|
||||||
|
onOpenChange={(open) => !open && setQuestionToDelete(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Question</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this question? This action cannot
|
||||||
|
be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -3,9 +3,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
import type {
|
||||||
import type { Question } from '@/types/questions'
|
EditCardPackDto,
|
||||||
import { questionFromJson, questionToJson } from '@/types/questions'
|
CardPackPreviewDto,
|
||||||
|
PaginatedResponse,
|
||||||
|
Question,
|
||||||
|
} from '@/types/models'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
@ -39,8 +42,8 @@ import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
|
import { GameQuestionsManager } from '@/components/GameQuestionsManager'
|
||||||
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
|
import { GameQuestionEditorDialog } from '@/components/GameQuestionEditorDialog'
|
||||||
|
|
||||||
export default function PacksPage() {
|
export default function PacksPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -151,9 +154,17 @@ export default function PacksPage() {
|
||||||
const fullPack = await packsApi.getPack(pack.id)
|
const fullPack = await packsApi.getPack(pack.id)
|
||||||
setSelectedPack(fullPack)
|
setSelectedPack(fullPack)
|
||||||
|
|
||||||
// Convert questions from backend format to frontend format
|
// Convert questions from backend format (supports both 'question' and 'text' fields)
|
||||||
const questions: Question[] = Array.isArray(fullPack.questions)
|
const questions: Question[] = Array.isArray(fullPack.questions)
|
||||||
? fullPack.questions.map((q: unknown) => questionFromJson(q))
|
? fullPack.questions.map((q: any) => ({
|
||||||
|
question: q.question || q.text || '',
|
||||||
|
answers: Array.isArray(q.answers)
|
||||||
|
? q.answers.map((a: any) => ({
|
||||||
|
text: a.text || '',
|
||||||
|
points: typeof a.points === 'number' ? a.points : 0,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
|
|
@ -197,9 +208,7 @@ export default function PacksPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert questions to backend format
|
// Questions are already in the correct format for backend
|
||||||
const questionsForBackend = formData.questions.map(q => questionToJson(q))
|
|
||||||
|
|
||||||
const packData: EditCardPackDto = {
|
const packData: EditCardPackDto = {
|
||||||
// Only include id for updates, not for new packs
|
// Only include id for updates, not for new packs
|
||||||
...(selectedPack && { id: selectedPack.id }),
|
...(selectedPack && { id: selectedPack.id }),
|
||||||
|
|
@ -207,7 +216,7 @@ export default function PacksPage() {
|
||||||
description: formData.description.trim(),
|
description: formData.description.trim(),
|
||||||
category: formData.category.trim(),
|
category: formData.category.trim(),
|
||||||
isPublic: formData.isPublic,
|
isPublic: formData.isPublic,
|
||||||
questions: questionsForBackend as any, // Backend expects different format
|
questions: formData.questions,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedPack) {
|
if (selectedPack) {
|
||||||
|
|
@ -507,7 +516,7 @@ export default function PacksPage() {
|
||||||
Add Question
|
Add Question
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TestQuestionsManager
|
<GameQuestionsManager
|
||||||
questions={formData.questions}
|
questions={formData.questions}
|
||||||
onChange={handleQuestionsChange}
|
onChange={handleQuestionsChange}
|
||||||
onEdit={handleEditQuestion}
|
onEdit={handleEditQuestion}
|
||||||
|
|
@ -528,7 +537,7 @@ export default function PacksPage() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Question Editor Dialog */}
|
{/* Question Editor Dialog */}
|
||||||
<QuestionEditorDialog
|
<GameQuestionEditorDialog
|
||||||
open={isQuestionEditorOpen}
|
open={isQuestionEditorOpen}
|
||||||
question={editingQuestion?.question || null}
|
question={editingQuestion?.question || null}
|
||||||
onSave={handleSaveQuestion}
|
onSave={handleSaveQuestion}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue