diff --git a/mnemo_cards_admin/src/components/layout/Layout.tsx b/mnemo_cards_admin/src/components/layout/Layout.tsx
index 08a71e1..73db32c 100644
--- a/mnemo_cards_admin/src/components/layout/Layout.tsx
+++ b/mnemo_cards_admin/src/components/layout/Layout.tsx
@@ -7,6 +7,7 @@ import {
FileText,
Package,
Users,
+ ClipboardList,
LogOut,
Menu,
X
@@ -21,6 +22,7 @@ const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Cards', href: '/cards', icon: FileText },
{ name: 'Packs', href: '/packs', icon: Package },
+ { name: 'Tests', href: '/tests', icon: ClipboardList },
{ name: 'Users', href: '/users', icon: Users },
]
diff --git a/mnemo_cards_admin/src/components/ui/audio-upload.tsx b/mnemo_cards_admin/src/components/ui/audio-upload.tsx
index ce88d69..d7135cc 100644
--- a/mnemo_cards_admin/src/components/ui/audio-upload.tsx
+++ b/mnemo_cards_admin/src/components/ui/audio-upload.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from 'react'
+import { useRef, useState, useEffect } from 'react'
import { Button } from './button'
import { Label } from './label'
import { Input } from './input'
@@ -110,29 +110,63 @@ export function AudioUpload({
}
}
+ // Cleanup audio on unmount or value change
+ useEffect(() => {
+ return () => {
+ if (audioRef.current) {
+ audioRef.current.pause()
+ audioRef.current = null
+ }
+ setIsPlaying(false)
+ }
+ }, [value])
+
const handlePlayPause = () => {
if (!value) return
if (!audioRef.current) {
- const audio = new Audio(`data:audio/mpeg;base64,${value}`)
- audioRef.current = audio
- audio.onended = () => {
+ try {
+ const audio = new Audio(`data:audio/mpeg;base64,${value}`)
+ audioRef.current = audio
+
+ audio.onended = () => {
+ setIsPlaying(false)
+ audioRef.current = null
+ }
+
+ audio.onerror = (e) => {
+ console.error('Audio playback error:', e)
+ alert('Failed to play audio. The file may be corrupted or in an unsupported format.')
+ setIsPlaying(false)
+ audioRef.current = null
+ }
+
+ audio.onloadstart = () => {
+ setIsPlaying(true)
+ }
+
+ audio.play().catch((error) => {
+ console.error('Audio play error:', error)
+ alert('Failed to play audio. Please check your browser audio settings.')
+ setIsPlaying(false)
+ audioRef.current = null
+ })
+ } catch (error) {
+ console.error('Error creating audio element:', error)
+ alert('Failed to initialize audio player')
setIsPlaying(false)
- audioRef.current = null
}
- audio.onerror = () => {
- alert('Failed to play audio')
- setIsPlaying(false)
- audioRef.current = null
- }
- }
-
- if (isPlaying) {
- audioRef.current?.pause()
- setIsPlaying(false)
} else {
- audioRef.current?.play()
- setIsPlaying(true)
+ if (isPlaying) {
+ audioRef.current.pause()
+ setIsPlaying(false)
+ } else {
+ audioRef.current.play().catch((error) => {
+ console.error('Audio play error:', error)
+ alert('Failed to resume audio playback')
+ })
+ setIsPlaying(true)
+ }
}
}
@@ -142,23 +176,39 @@ export function AudioUpload({
{value ? (
-
-
+
+
-
-
Audio file loaded
+
+
+ Audio file loaded
+ {isPlaying && (
+ Playing...
+ )}
+
diff --git a/mnemo_cards_admin/src/pages/TestsPage.tsx b/mnemo_cards_admin/src/pages/TestsPage.tsx
new file mode 100644
index 0000000..60a86f5
--- /dev/null
+++ b/mnemo_cards_admin/src/pages/TestsPage.tsx
@@ -0,0 +1,491 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { testsApi, isTestsApiError } from '@/api/tests'
+import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
+import type { TestDto, PaginatedResponse } 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'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { ImageUpload } from '@/components/ui/image-upload'
+import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
+
+export default function TestsPage() {
+ const queryClient = useQueryClient()
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState('')
+ const [selectedTest, setSelectedTest] = useState
(null)
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
+ const [testToDelete, setTestToDelete] = useState(null)
+
+ // Form state
+ const [formData, setFormData] = useState({
+ name: '',
+ color: '',
+ cover: undefined as string | undefined,
+ version: '',
+ time: '',
+ timeSubtitle: '',
+ questions: [] as TestDto['questions'],
+ })
+
+ const limit = 20
+
+ // Fetch tests
+ const { data, isLoading, error } = useQuery>({
+ queryKey: ['tests', page, search],
+ queryFn: () => testsApi.getTests({ page, limit, search }),
+ retry: (failureCount, error) => {
+ // Don't retry on client errors (4xx)
+ if (isTestsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
+ return false
+ }
+ return failureCount < 2
+ },
+ })
+
+ // Mutations
+ const createMutation = useMutation({
+ mutationFn: (test: TestDto) => testsApi.upsertTest(test),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tests'] })
+ toast.success('Test created successfully')
+ closeDialog()
+ },
+ onError: (error: unknown) => {
+ const errorMessage = isTestsApiError(error)
+ ? error.message
+ : getDetailedErrorMessage(error, 'create', 'test')
+ toast.error(errorMessage)
+ console.error('Error creating test:', error)
+ },
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: (test: TestDto) => testsApi.upsertTest(test),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tests'] })
+ toast.success('Test updated successfully')
+ closeDialog()
+ },
+ onError: (error: unknown) => {
+ const errorMessage = isTestsApiError(error)
+ ? error.message
+ : getDetailedErrorMessage(error, 'update', 'test')
+ toast.error(errorMessage)
+ console.error('Error updating test:', error)
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (testId: string) => testsApi.deleteTest(testId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tests'] })
+ toast.success('Test deleted successfully')
+ setIsDeleteDialogOpen(false)
+ setTestToDelete(null)
+ },
+ onError: (error: unknown) => {
+ const errorMessage = isTestsApiError(error)
+ ? error.message
+ : getDetailedErrorMessage(error, 'delete', 'test')
+ toast.error(errorMessage)
+ console.error('Error deleting test:', error)
+ },
+ })
+
+ const openCreateDialog = () => {
+ setSelectedTest(null)
+ setFormData({
+ name: '',
+ color: '',
+ cover: undefined,
+ version: '',
+ time: '',
+ timeSubtitle: '',
+ questions: [],
+ })
+ setIsDialogOpen(true)
+ }
+
+ const openEditDialog = async (test: TestDto) => {
+ try {
+ // Load full test data if we only have preview
+ const fullTest = test.id ? await testsApi.getTest(test.id) : test
+ setSelectedTest(fullTest)
+ setFormData({
+ name: fullTest.name || '',
+ color: fullTest.color || '',
+ cover: fullTest.cover,
+ version: fullTest.version || '',
+ time: fullTest.time || '',
+ timeSubtitle: fullTest.timeSubtitle || '',
+ questions: fullTest.questions || [],
+ })
+ setIsDialogOpen(true)
+ } catch (error) {
+ const errorMessage = isTestsApiError(error)
+ ? error.message
+ : getDetailedErrorMessage(error, 'load', `test "${test.id}"`)
+ toast.error(errorMessage)
+ console.error('Error loading test details:', error)
+ }
+ }
+
+ const closeDialog = () => {
+ setIsDialogOpen(false)
+ setSelectedTest(null)
+ }
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!formData.name.trim()) {
+ toast.error('Name is required')
+ return
+ }
+
+ const testData: TestDto = {
+ id: selectedTest?.id,
+ name: formData.name.trim(),
+ color: formData.color.trim() || undefined,
+ cover: formData.cover || undefined,
+ version: formData.version.trim() || undefined,
+ time: formData.time.trim() || undefined,
+ timeSubtitle: formData.timeSubtitle.trim() || undefined,
+ questions: formData.questions,
+ }
+
+ if (selectedTest) {
+ updateMutation.mutate(testData)
+ } else {
+ createMutation.mutate(testData)
+ }
+ }
+
+ const handleDelete = (test: TestDto) => {
+ if (!test.id) {
+ toast.error('Test ID is required for deletion')
+ return
+ }
+ setTestToDelete(test)
+ setIsDeleteDialogOpen(true)
+ }
+
+ const confirmDelete = () => {
+ if (testToDelete?.id) {
+ deleteMutation.mutate(testToDelete.id)
+ }
+ }
+
+ const handleSearch = (value: string) => {
+ setSearch(value)
+ setPage(1) // Reset to first page when searching
+ }
+
+ if (error) {
+ const errorMessage = isTestsApiError(error)
+ ? error.message
+ : formatApiError(error)
+ return (
+
+
+
Tests Management
+
Error loading tests
+
+
+
+
+
Failed to load tests
+
{errorMessage}
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Tests Management
+
+ View, create, edit and delete game tests
+
+
+
+
+
+ {/* Search */}
+
+
+
+
+ handleSearch(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+
+
+ {/* Tests Table */}
+
+
+ All Tests ({data?.total || 0})
+
+ Manage game tests in the system
+
+
+
+ {isLoading ? (
+ Loading tests...
+ ) : (
+ <>
+
+
+
+ ID
+ Name
+ Questions
+ Version
+ Time
+ Actions
+
+
+
+ {data?.items.map((test) => (
+
+ {test.id || 'N/A'}
+ {test.name}
+ {test.questions?.length || 0}
+ {test.version || 'N/A'}
+ {test.time || 'N/A'}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ {/* Pagination */}
+ {data && data.totalPages > 1 && (
+
+
+ Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} tests
+
+
+
+
+ Page {page} of {data.totalPages}
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ {/* Create/Edit Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Delete Test
+
+ Are you sure you want to delete the test "{testToDelete?.name}"? This action cannot be undone.
+
+
+
+ Cancel
+
+ {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+ )
+}
diff --git a/mnemo_cards_admin/src/types/models.ts b/mnemo_cards_admin/src/types/models.ts
index 27de49a..4a2fdfa 100644
--- a/mnemo_cards_admin/src/types/models.ts
+++ b/mnemo_cards_admin/src/types/models.ts
@@ -157,3 +157,22 @@ export interface CodeStatusResponse {
error?: string
message?: string
}
+
+// Test types
+export interface TestQuestion {
+ id?: string
+ questionType: string
+ body: unknown // JSON structure varies by question type
+}
+
+export interface TestDto {
+ id?: string
+ name: string
+ color?: string
+ cover?: string
+ version?: string
+ time?: string
+ timeSubtitle?: string
+ questions: TestQuestion[]
+ statistics?: unknown
+}
diff --git a/mnemo_cards_backend/README.md b/mnemo_cards_backend/README.md
index f8c9663..0348848 100644
--- a/mnemo_cards_backend/README.md
+++ b/mnemo_cards_backend/README.md
@@ -217,11 +217,16 @@ mnemo_cards_backend/
│ │ │ ├── packs.dart
│ │ │ ├── auth.dart
│ │ │ ├── payments.dart
+│ │ │ ├── word_statistics.dart # Статистика ответов на карточки
+│ │ │ ├── audit.dart # Audit trail (инфраструктура)
│ │ │ └── ...
│ │ └── daos/ # Data Access Objects
│ │ ├── user_dao.dart
│ │ ├── pack_dao.dart
-│ │ └── ...
+│ │ ├── word_statistics_dao.dart # Работа со статистикой слов
+│ │ ├── audit_dao.dart # Audit logging
+│ │ └── mixins/
+│ │ └── soft_delete_mixin.dart # Soft delete функциональность
│ │
│ ├── user/ # User management
│ │ └── user_manager.dart
@@ -245,6 +250,7 @@ mnemo_cards_backend/
│ ├── statistics/ # User statistics
│ │ ├── session_tracker.dart
│ │ ├── statistics_calculator.dart
+│ │ ├── word_statistics_manager.dart # Управление статистикой ответов
│ │ └── session_tracking_middleware.dart
│ │
│ ├── cron/ # Background jobs
diff --git a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart
index cf290c3..a1e59dd 100644
--- a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart
+++ b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart
@@ -33,12 +33,12 @@ class AdminCardsApiV2 {
/// Get all cards with pagination and search
@Route.get('/admin/cards')
Future getAllCards(Request request) async {
+ final auth = await _ensureAdmin(request);
+ if (auth.statusCode != 200) {
+ return auth;
+ }
+
try {
- final auth = await _ensureAdmin(request);
- if (auth.statusCode != 200) {
- return auth;
- }
-
final queryParams = request.url.queryParameters;
// Parse pagination parameters
@@ -91,24 +91,29 @@ class AdminCardsApiV2 {
final offset = (page - 1) * limit;
final paginatedCards = filteredCards.skip(offset).take(limit).toList();
+ // Получить паки для всех карточек (для packId)
+ final cardsWithPacks =