This commit is contained in:
Dmitry 2026-01-07 17:10:14 +03:00
parent 7604fe2004
commit 5983bc5bed
3 changed files with 419 additions and 13 deletions

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

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

View file

@ -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}