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 { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { questionFromJson, questionToJson } from '@/types/questions'
|
||||
import type {
|
||||
EditCardPackDto,
|
||||
CardPackPreviewDto,
|
||||
PaginatedResponse,
|
||||
Question,
|
||||
} from '@/types/models'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
|
||||
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
|
||||
import { GameQuestionsManager } from '@/components/GameQuestionsManager'
|
||||
import { GameQuestionEditorDialog } from '@/components/GameQuestionEditorDialog'
|
||||
|
||||
export default function PacksPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -151,9 +154,17 @@ export default function PacksPage() {
|
|||
const fullPack = await packsApi.getPack(pack.id)
|
||||
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)
|
||||
? 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({
|
||||
|
|
@ -197,9 +208,7 @@ export default function PacksPage() {
|
|||
return
|
||||
}
|
||||
|
||||
// Convert questions to backend format
|
||||
const questionsForBackend = formData.questions.map(q => questionToJson(q))
|
||||
|
||||
// Questions are already in the correct format for backend
|
||||
const packData: EditCardPackDto = {
|
||||
// Only include id for updates, not for new packs
|
||||
...(selectedPack && { id: selectedPack.id }),
|
||||
|
|
@ -207,7 +216,7 @@ export default function PacksPage() {
|
|||
description: formData.description.trim(),
|
||||
category: formData.category.trim(),
|
||||
isPublic: formData.isPublic,
|
||||
questions: questionsForBackend as any, // Backend expects different format
|
||||
questions: formData.questions,
|
||||
}
|
||||
|
||||
if (selectedPack) {
|
||||
|
|
@ -507,7 +516,7 @@ export default function PacksPage() {
|
|||
Add Question
|
||||
</Button>
|
||||
</div>
|
||||
<TestQuestionsManager
|
||||
<GameQuestionsManager
|
||||
questions={formData.questions}
|
||||
onChange={handleQuestionsChange}
|
||||
onEdit={handleEditQuestion}
|
||||
|
|
@ -528,7 +537,7 @@ export default function PacksPage() {
|
|||
</Dialog>
|
||||
|
||||
{/* Question Editor Dialog */}
|
||||
<QuestionEditorDialog
|
||||
<GameQuestionEditorDialog
|
||||
open={isQuestionEditorOpen}
|
||||
question={editingQuestion?.question || null}
|
||||
onSave={handleSaveQuestion}
|
||||
|
|
|
|||
Loading…
Reference in a new issue