stuff
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
79318528df
commit
3d179c95ef
36 changed files with 4705 additions and 317 deletions
|
|
@ -7,37 +7,40 @@ import PacksPage from '@/pages/PacksPage'
|
|||
import UsersPage from '@/pages/UsersPage'
|
||||
import TestsPage from '@/pages/TestsPage'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/cards" element={<CardsPage />} />
|
||||
<Route path="/packs" element={<PacksPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/tests" element={<TestsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
<TokenRefreshProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/cards" element={<CardsPage />} />
|
||||
<Route path="/packs" element={<PacksPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/tests" element={<TestsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</TokenRefreshProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { AuthResponse, RequestCodeResponse, CodeStatusResponse } from '@/types/models'
|
||||
import type { AuthResponse, RequestCodeResponse, CodeStatusResponse, RefreshTokenResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export const authApi = {
|
||||
|
|
@ -46,4 +46,12 @@ export const authApi = {
|
|||
const response = await adminApiClient.get('/api/v2/admin/auth/me')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Refresh access token using refresh token
|
||||
refreshToken: async (refreshToken: string): Promise<RefreshTokenResponse> => {
|
||||
const response = await adminApiClient.post('/api/v2/admin/auth/refresh', {
|
||||
refreshToken,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authApi } from './auth'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online'
|
||||
|
||||
|
|
@ -59,22 +60,122 @@ const addAuthToken = (config: InternalAxiosRequestConfig) => {
|
|||
|
||||
// Flag to prevent multiple logout calls
|
||||
let isLoggingOut = false
|
||||
// Flag to prevent multiple refresh calls
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
|
||||
// Function to refresh token
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
const response = await authApi.refreshToken(refreshToken)
|
||||
if (!response.success || !response.token) {
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
|
||||
// Update store with new tokens
|
||||
useAuthStore.getState().updateToken(
|
||||
response.token,
|
||||
response.refreshToken,
|
||||
response.expiresIn
|
||||
)
|
||||
|
||||
return response.token
|
||||
} catch (error) {
|
||||
console.error('[API Client] Token refresh failed:', error)
|
||||
// If refresh fails, logout
|
||||
useAuthStore.getState().logout()
|
||||
throw error
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
// Request interceptor to check and refresh token if needed
|
||||
const checkAndRefreshToken = async (config: InternalAxiosRequestConfig) => {
|
||||
const isPublicAuthEndpoint =
|
||||
config.url?.includes('/admin/auth/request-code') ||
|
||||
config.url?.includes('/admin/auth/verify-code') ||
|
||||
config.url?.includes('/admin/auth/code-status') ||
|
||||
config.url?.includes('/admin/auth/refresh')
|
||||
|
||||
// Skip token refresh for public endpoints
|
||||
if (isPublicAuthEndpoint) {
|
||||
return config
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
if (useAuthStore.getState().shouldRefreshToken() && !isRefreshing) {
|
||||
isRefreshing = true
|
||||
try {
|
||||
const newToken = await refreshAccessToken()
|
||||
// Update token in config
|
||||
if (config.headers) {
|
||||
config.headers['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[API Client] Failed to refresh token before request:', error)
|
||||
// Will be handled by response interceptor
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// Response interceptor for error handling
|
||||
const handleAuthError = (error: unknown) => {
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
const axiosError = error as AxiosError
|
||||
const originalRequest = axiosError.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (axiosError.response?.status === 401) {
|
||||
// Check if we're already on login page
|
||||
const currentPath = window.location.pathname
|
||||
const isPublicAuthEndpoint =
|
||||
axiosError.config?.url?.includes('/admin/auth/request-code') ||
|
||||
axiosError.config?.url?.includes('/admin/auth/verify-code') ||
|
||||
axiosError.config?.url?.includes('/admin/auth/code-status')
|
||||
originalRequest?.url?.includes('/admin/auth/request-code') ||
|
||||
originalRequest?.url?.includes('/admin/auth/verify-code') ||
|
||||
originalRequest?.url?.includes('/admin/auth/code-status') ||
|
||||
originalRequest?.url?.includes('/admin/auth/refresh')
|
||||
|
||||
// Don't logout for public auth endpoints or if already on login page
|
||||
if (currentPath === '/login' || isPublicAuthEndpoint) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Try to refresh token if we haven't retried yet
|
||||
if (!originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
|
||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const newToken = await refreshAccessToken()
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
|
||||
return adminApiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
console.error('[API Client] Token refresh failed, logging out:', refreshError)
|
||||
// Refresh failed, proceed to logout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent multiple logout calls
|
||||
if (isLoggingOut) {
|
||||
|
|
@ -86,6 +187,7 @@ const handleAuthError = (error: unknown) => {
|
|||
// Remove token and update auth store
|
||||
// React Router will automatically redirect to /login when isAuthenticated becomes false
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
useAuthStore.getState().logout()
|
||||
|
||||
// Reset flag after a short delay to allow React Router to handle redirect
|
||||
|
|
@ -97,8 +199,10 @@ const handleAuthError = (error: unknown) => {
|
|||
}
|
||||
|
||||
// Apply interceptors to both clients
|
||||
apiClient.interceptors.request.use(checkAndRefreshToken)
|
||||
apiClient.interceptors.request.use(addAuthToken)
|
||||
apiClient.interceptors.response.use((response) => response, handleAuthError)
|
||||
|
||||
adminApiClient.interceptors.request.use(checkAndRefreshToken)
|
||||
adminApiClient.interceptors.request.use(addAuthToken)
|
||||
adminApiClient.interceptors.response.use((response) => response, handleAuthError)
|
||||
|
|
|
|||
160
mnemo_cards_admin/src/components/JSONQuestionEditor.tsx
Normal file
160
mnemo_cards_admin/src/components/JSONQuestionEditor.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { questionToJson, questionFromJson } from '@/types/questions'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
interface JSONQuestionEditorProps {
|
||||
question: Partial<Question> | null
|
||||
onChange: (question: Partial<Question> | null, isValid: boolean) => void
|
||||
}
|
||||
|
||||
export function JSONQuestionEditor({
|
||||
question,
|
||||
onChange,
|
||||
}: JSONQuestionEditorProps) {
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
|
||||
// Инициализация JSON из вопроса
|
||||
useEffect(() => {
|
||||
if (question) {
|
||||
try {
|
||||
const json = questionToJson(question as Question)
|
||||
setJsonText(JSON.stringify(json, null, 2))
|
||||
setError(null)
|
||||
setIsValid(true)
|
||||
} catch (e) {
|
||||
setError(`Failed to serialize question: ${e}`)
|
||||
setIsValid(false)
|
||||
}
|
||||
} else {
|
||||
setJsonText('')
|
||||
setError(null)
|
||||
setIsValid(false)
|
||||
}
|
||||
}, [question])
|
||||
|
||||
// Валидация и парсинг JSON
|
||||
const handleJsonChange = (value: string) => {
|
||||
setJsonText(value)
|
||||
|
||||
if (!value.trim()) {
|
||||
setError(null)
|
||||
setIsValid(false)
|
||||
onChange(null, false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
// Базовая валидация структуры
|
||||
if (!parsed.questionType) {
|
||||
throw new Error('Missing required field: questionType')
|
||||
}
|
||||
if (!parsed.word) {
|
||||
throw new Error('Missing required field: word')
|
||||
}
|
||||
if (!parsed.answer) {
|
||||
throw new Error('Missing required field: answer')
|
||||
}
|
||||
if (!parsed.buttons || !Array.isArray(parsed.buttons)) {
|
||||
throw new Error('Missing or invalid field: buttons (must be an array)')
|
||||
}
|
||||
|
||||
// Проверка что answer существует в buttons
|
||||
const buttonIds = parsed.buttons.map((b: any) => b.id)
|
||||
if (!buttonIds.includes(parsed.answer)) {
|
||||
throw new Error(
|
||||
`Answer "${parsed.answer}" not found in buttons. Available IDs: ${buttonIds.join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Проверка для input_buttons
|
||||
if (parsed.questionType === 'input_buttons' && !parsed.template) {
|
||||
throw new Error('Missing required field for input_buttons: template')
|
||||
}
|
||||
|
||||
// Парсинг в Question объект
|
||||
const questionObj = questionFromJson(parsed)
|
||||
setError(null)
|
||||
setIsValid(true)
|
||||
onChange(questionObj, true)
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON'
|
||||
setError(errorMessage)
|
||||
setIsValid(false)
|
||||
onChange(null, false)
|
||||
}
|
||||
}
|
||||
|
||||
// Форматирование JSON
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
setJsonText(JSON.stringify(parsed, null, 2))
|
||||
handleJsonChange(JSON.stringify(parsed, null, 2))
|
||||
} catch (e) {
|
||||
// Если невалидный JSON, просто показываем ошибку
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>JSON Editor</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={formatJson}>
|
||||
Format JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
placeholder='{"questionType": "simple", "word": "...", "answer": "...", "buttons": [...]}'
|
||||
rows={20}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isValid && !error && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>JSON is valid</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-semibold mb-1">Required fields:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<code>questionType</code>: "simple" or "input_buttons"
|
||||
</li>
|
||||
<li>
|
||||
<code>word</code>: Word or phrase to learn
|
||||
</li>
|
||||
<li>
|
||||
<code>answer</code>: ID of the correct button
|
||||
</li>
|
||||
<li>
|
||||
<code>buttons</code>: Array of button objects with id, text/image
|
||||
</li>
|
||||
<li>
|
||||
<code>template</code>: Required for input_buttons type
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
mnemo_cards_admin/src/components/QuestionEditorDialog.tsx
Normal file
243
mnemo_cards_admin/src/components/QuestionEditorDialog.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { QuestionType, isSimpleQuestion, isInputButtonsQuestion } from '@/types/questions'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { SimpleQuestionForm } from './forms/SimpleQuestionForm'
|
||||
import { InputButtonsQuestionForm } from './forms/InputButtonsQuestionForm'
|
||||
import { JSONQuestionEditor } from './JSONQuestionEditor'
|
||||
import { Code, FileText } from 'lucide-react'
|
||||
|
||||
type EditorMode = 'visual' | 'json'
|
||||
|
||||
interface QuestionEditorDialogProps {
|
||||
open: boolean
|
||||
question: Question | null
|
||||
onSave: (question: Question) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QuestionEditorDialog({
|
||||
open,
|
||||
question,
|
||||
onSave,
|
||||
onClose,
|
||||
}: QuestionEditorDialogProps) {
|
||||
const [mode, setMode] = useState<EditorMode>('visual')
|
||||
const [questionType, setQuestionType] = useState<QuestionType>(
|
||||
question?.questionType || QuestionType.SIMPLE,
|
||||
)
|
||||
const [currentQuestion, setCurrentQuestion] = useState<Partial<Question> | null>(
|
||||
question || null,
|
||||
)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
|
||||
// Инициализация при открытии
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (question) {
|
||||
setCurrentQuestion(question)
|
||||
setQuestionType(question.questionType)
|
||||
setMode('visual')
|
||||
} else {
|
||||
// Новый вопрос
|
||||
setCurrentQuestion({
|
||||
questionType: QuestionType.SIMPLE,
|
||||
word: '',
|
||||
answer: '',
|
||||
options: [],
|
||||
})
|
||||
setQuestionType(QuestionType.SIMPLE)
|
||||
setMode('visual')
|
||||
}
|
||||
setIsValid(false)
|
||||
}
|
||||
}, [open, question])
|
||||
|
||||
const handleQuestionChange = (
|
||||
updated: Partial<Question> | null,
|
||||
valid: boolean,
|
||||
) => {
|
||||
setCurrentQuestion(updated)
|
||||
setIsValid(valid)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!currentQuestion || !isValid) {
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация обязательных полей
|
||||
if (!currentQuestion.word || !currentQuestion.answer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentQuestion.options || currentQuestion.options.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isInputButtonsQuestion(currentQuestion as Question) &&
|
||||
!(currentQuestion as any).template
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onSave(currentQuestion as Question)
|
||||
}
|
||||
|
||||
const handleTypeChange = (newType: QuestionType) => {
|
||||
setQuestionType(newType)
|
||||
if (currentQuestion) {
|
||||
const updated: Partial<Question> = {
|
||||
...currentQuestion,
|
||||
questionType: newType,
|
||||
}
|
||||
|
||||
// Если переключаемся на input_buttons и нет template, добавляем пустой
|
||||
if (newType === QuestionType.INPUT_BUTTONS && !(updated as any).template) {
|
||||
;(updated as any).template = ''
|
||||
}
|
||||
|
||||
setCurrentQuestion(updated)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl 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 test'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Режим редактирования */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Label>Editor Mode:</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === 'visual' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setMode('visual')}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Visual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === 'json' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setMode('json')}
|
||||
>
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Тип вопроса (только в visual режиме) */}
|
||||
{mode === 'visual' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="question-type">Type:</Label>
|
||||
<Select
|
||||
value={questionType}
|
||||
onValueChange={(value) => handleTypeChange(value as QuestionType)}
|
||||
>
|
||||
<SelectTrigger id="question-type" className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={QuestionType.SIMPLE}>Simple</SelectItem>
|
||||
<SelectItem value={QuestionType.INPUT_BUTTONS}>
|
||||
Input Buttons
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Контент редактора */}
|
||||
{mode === 'visual' ? (
|
||||
<div>
|
||||
{questionType === QuestionType.SIMPLE ? (
|
||||
<SimpleQuestionForm
|
||||
question={currentQuestion || {}}
|
||||
onChange={(updated) => {
|
||||
setCurrentQuestion({
|
||||
...updated,
|
||||
questionType: QuestionType.SIMPLE,
|
||||
})
|
||||
setIsValid(
|
||||
!!(updated.word && updated.answer && updated.options?.length),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InputButtonsQuestionForm
|
||||
question={currentQuestion || {}}
|
||||
onChange={(updated) => {
|
||||
setCurrentQuestion({
|
||||
...updated,
|
||||
questionType: QuestionType.INPUT_BUTTONS,
|
||||
})
|
||||
setIsValid(
|
||||
!!(
|
||||
updated.word &&
|
||||
updated.answer &&
|
||||
updated.options?.length &&
|
||||
(updated as any).template
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<JSONQuestionEditor
|
||||
question={currentQuestion}
|
||||
onChange={handleQuestionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!isValid || !currentQuestion}
|
||||
>
|
||||
{question ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
183
mnemo_cards_admin/src/components/TestQuestionsManager.tsx
Normal file
183
mnemo_cards_admin/src/components/TestQuestionsManager.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { useState } from 'react'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { QuestionType, isSimpleQuestion, isInputButtonsQuestion } from '@/types/questions'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Edit, Trash2, Plus, FileText, Type } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
interface TestQuestionsManagerProps {
|
||||
questions: Question[]
|
||||
onChange: (questions: Question[]) => void
|
||||
onEdit: (question: Question, index: number) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function TestQuestionsManager({
|
||||
questions,
|
||||
onChange,
|
||||
onEdit,
|
||||
disabled = false,
|
||||
}: TestQuestionsManagerProps) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const getQuestionPreview = (question: Question): string => {
|
||||
if (question.text) return question.text
|
||||
if (question.image) return '📷 Image question'
|
||||
return `Word: ${question.word}`
|
||||
}
|
||||
|
||||
const getQuestionTypeLabel = (type: QuestionType): string => {
|
||||
switch (type) {
|
||||
case QuestionType.SIMPLE:
|
||||
return 'Simple'
|
||||
case QuestionType.INPUT_BUTTONS:
|
||||
return 'Input Buttons'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const getQuestionTypeColor = (type: QuestionType): string => {
|
||||
switch (type) {
|
||||
case QuestionType.SIMPLE:
|
||||
return 'bg-blue-500'
|
||||
case QuestionType.INPUT_BUTTONS:
|
||||
return 'bg-green-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
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 test
|
||||
</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={question.id || 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">
|
||||
<Badge
|
||||
className={`${getQuestionTypeColor(
|
||||
question.questionType,
|
||||
)} text-white border-transparent`}
|
||||
>
|
||||
<Type className="h-3 w-3 mr-1" />
|
||||
{getQuestionTypeLabel(question.questionType)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{question.word}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getQuestionPreview(question)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{question.options.length} option(s)</span>
|
||||
{isInputButtonsQuestion(question) && (
|
||||
<span>• Template: {question.template}</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
52
mnemo_cards_admin/src/components/TokenRefreshProvider.tsx
Normal file
52
mnemo_cards_admin/src/components/TokenRefreshProvider.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
/**
|
||||
* Component that periodically checks and refreshes the access token
|
||||
* before it expires. This ensures seamless user experience without
|
||||
* unexpected logouts.
|
||||
*/
|
||||
export function TokenRefreshProvider({ children }: { children: React.ReactNode }) {
|
||||
const { shouldRefreshToken, refreshToken, updateToken, logout } = useAuthStore()
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Check every minute if token needs refresh
|
||||
const checkAndRefresh = async () => {
|
||||
if (!shouldRefreshToken() || !refreshToken) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[TokenRefreshProvider] Refreshing token...')
|
||||
const response = await authApi.refreshToken(refreshToken)
|
||||
|
||||
if (response.success && response.token) {
|
||||
updateToken(response.token, response.refreshToken, response.expiresIn)
|
||||
console.log('[TokenRefreshProvider] Token refreshed successfully')
|
||||
} else {
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TokenRefreshProvider] Token refresh failed:', error)
|
||||
// If refresh token is expired, logout
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately on mount
|
||||
checkAndRefresh()
|
||||
|
||||
// Set up interval to check every minute
|
||||
intervalRef.current = setInterval(checkAndRefresh, 60000) // 1 minute
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [shouldRefreshToken, refreshToken, updateToken, logout])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import type { InputButtonsQuestion, TestButton } from '@/types/questions'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
interface InputButtonsQuestionFormProps {
|
||||
question: Partial<InputButtonsQuestion>
|
||||
onChange: (question: Partial<InputButtonsQuestion>) => void
|
||||
}
|
||||
|
||||
export function InputButtonsQuestionForm({
|
||||
question,
|
||||
onChange,
|
||||
}: InputButtonsQuestionFormProps) {
|
||||
const [word, setWord] = useState(question.word || '')
|
||||
const [text, setText] = useState(question.text || '')
|
||||
const [image, setImage] = useState(question.image || '')
|
||||
const [audio, setAudio] = useState(question.audio || '')
|
||||
const [buttons, setButtons] = useState<TestButton[]>(question.options || [])
|
||||
const [answer, setAnswer] = useState(question.answer || '')
|
||||
const [template, setTemplate] = useState(question.template || '')
|
||||
|
||||
useEffect(() => {
|
||||
onChange({
|
||||
questionType: 'input_buttons',
|
||||
word,
|
||||
text: text || undefined,
|
||||
image: image || undefined,
|
||||
audio: audio || undefined,
|
||||
options: buttons,
|
||||
answer,
|
||||
template,
|
||||
})
|
||||
}, [word, text, image, audio, buttons, answer, template, onChange])
|
||||
|
||||
const addButton = () => {
|
||||
const newButton: TestButton = {
|
||||
id: `btn_${Date.now()}`,
|
||||
text: '',
|
||||
}
|
||||
setButtons([...buttons, newButton])
|
||||
}
|
||||
|
||||
const removeButton = (index: number) => {
|
||||
setButtons(buttons.filter((_, i) => i !== index))
|
||||
if (answer === buttons[index]?.id) {
|
||||
setAnswer('')
|
||||
}
|
||||
}
|
||||
|
||||
const updateButton = (index: number, updates: Partial<TestButton>) => {
|
||||
const newButtons = [...buttons]
|
||||
newButtons[index] = { ...newButtons[index], ...updates }
|
||||
setButtons(newButtons)
|
||||
}
|
||||
|
||||
// Генерация template из answer
|
||||
const generateTemplate = () => {
|
||||
if (answer) {
|
||||
setTemplate(answer.replace(/[^ ]/g, '_'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="word">
|
||||
Word <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="word"
|
||||
value={word}
|
||||
onChange={(e) => setWord(e.target.value)}
|
||||
placeholder="Word or phrase to learn"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Question Text</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Optional question text"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Question Image"
|
||||
value={image}
|
||||
onChange={setImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audio">Audio URL</Label>
|
||||
<Input
|
||||
id="audio"
|
||||
value={audio}
|
||||
onChange={(e) => setAudio(e.target.value)}
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Answer Options <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addButton}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Button
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{buttons.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No buttons yet. Add at least one button.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((button, index) => (
|
||||
<Card key={button.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Button {index + 1} (ID: {button.id})
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeButton(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`button-text-${index}`}>Text</Label>
|
||||
<Input
|
||||
id={`button-text-${index}`}
|
||||
value={button.text || ''}
|
||||
onChange={(e) =>
|
||||
updateButton(index, { text: e.target.value })
|
||||
}
|
||||
placeholder="Button text"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Image"
|
||||
value={button.image || ''}
|
||||
onChange={(value) =>
|
||||
updateButton(index, { image: value || undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="answer">
|
||||
Correct Answer <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={answer} onValueChange={setAnswer} required>
|
||||
<SelectTrigger id="answer">
|
||||
<SelectValue placeholder="Select correct answer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{buttons.map((button) => (
|
||||
<SelectItem key={button.id} value={button.id}>
|
||||
{button.text || button.image || button.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{buttons.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add at least one button to select an answer
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="template">
|
||||
Template <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{answer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generateTemplate}
|
||||
>
|
||||
Generate from answer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="template"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="e.g., a___e (underscores for letters to input)"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Template defines which letters the user needs to input. Use underscores
|
||||
(_) for letters to input, spaces for word separators.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
mnemo_cards_admin/src/components/forms/SimpleQuestionForm.tsx
Normal file
205
mnemo_cards_admin/src/components/forms/SimpleQuestionForm.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import type { SimpleQuestion, TestButton } from '@/types/questions'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
interface SimpleQuestionFormProps {
|
||||
question: Partial<SimpleQuestion>
|
||||
onChange: (question: Partial<SimpleQuestion>) => void
|
||||
}
|
||||
|
||||
export function SimpleQuestionForm({
|
||||
question,
|
||||
onChange,
|
||||
}: SimpleQuestionFormProps) {
|
||||
const [word, setWord] = useState(question.word || '')
|
||||
const [text, setText] = useState(question.text || '')
|
||||
const [image, setImage] = useState(question.image || '')
|
||||
const [audio, setAudio] = useState(question.audio || '')
|
||||
const [buttons, setButtons] = useState<TestButton[]>(question.options || [])
|
||||
const [answer, setAnswer] = useState(question.answer || '')
|
||||
|
||||
useEffect(() => {
|
||||
onChange({
|
||||
questionType: 'simple',
|
||||
word,
|
||||
text: text || undefined,
|
||||
image: image || undefined,
|
||||
audio: audio || undefined,
|
||||
options: buttons,
|
||||
answer,
|
||||
})
|
||||
}, [word, text, image, audio, buttons, answer, onChange])
|
||||
|
||||
const addButton = () => {
|
||||
const newButton: TestButton = {
|
||||
id: `btn_${Date.now()}`,
|
||||
text: '',
|
||||
}
|
||||
setButtons([...buttons, newButton])
|
||||
}
|
||||
|
||||
const removeButton = (index: number) => {
|
||||
setButtons(buttons.filter((_, i) => i !== index))
|
||||
if (answer === buttons[index]?.id) {
|
||||
setAnswer('')
|
||||
}
|
||||
}
|
||||
|
||||
const updateButton = (index: number, updates: Partial<TestButton>) => {
|
||||
const newButtons = [...buttons]
|
||||
newButtons[index] = { ...newButtons[index], ...updates }
|
||||
setButtons(newButtons)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="word">
|
||||
Word <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="word"
|
||||
value={word}
|
||||
onChange={(e) => setWord(e.target.value)}
|
||||
placeholder="Word or phrase to learn"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Question Text</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Optional question text"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Question Image"
|
||||
value={image}
|
||||
onChange={setImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audio">Audio URL</Label>
|
||||
<Input
|
||||
id="audio"
|
||||
value={audio}
|
||||
onChange={(e) => setAudio(e.target.value)}
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Answer Options <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addButton}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Button
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{buttons.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No buttons yet. Add at least one button.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((button, index) => (
|
||||
<Card key={button.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Button {index + 1} (ID: {button.id})
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeButton(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`button-text-${index}`}>Text</Label>
|
||||
<Input
|
||||
id={`button-text-${index}`}
|
||||
value={button.text || ''}
|
||||
onChange={(e) =>
|
||||
updateButton(index, { text: e.target.value })
|
||||
}
|
||||
placeholder="Button text"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Image"
|
||||
value={button.image || ''}
|
||||
onChange={(value) =>
|
||||
updateButton(index, { image: value || undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="answer">
|
||||
Correct Answer <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={answer} onValueChange={setAnswer} required>
|
||||
<SelectTrigger id="answer">
|
||||
<SelectValue placeholder="Select correct answer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{buttons.map((button) => (
|
||||
<SelectItem key={button.id} value={button.id}>
|
||||
{button.text || button.image || button.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{buttons.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add at least one button to select an answer
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
mnemo_cards_admin/src/components/ui/alert.tsx
Normal file
59
mnemo_cards_admin/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
|
|
@ -95,14 +95,18 @@ export default function LoginPage() {
|
|||
|
||||
console.log('Login successful, setting token and user')
|
||||
console.log('Token received:', data.token ? `Token exists, length: ${data.token.length}` : 'NO TOKEN!')
|
||||
console.log('RefreshToken received:', data.refreshToken ? `Token exists, length: ${data.refreshToken.length}` : 'NO REFRESH TOKEN!')
|
||||
console.log('ExpiresIn:', data.expiresIn)
|
||||
console.log('User received:', data.user)
|
||||
|
||||
// Save token first
|
||||
login(data.token, data.user)
|
||||
// Save token with refresh token and expiration
|
||||
login(data.token, data.refreshToken ?? null, data.expiresIn ?? null, data.user)
|
||||
|
||||
// Verify token was saved
|
||||
const savedToken = localStorage.getItem('admin_token')
|
||||
const savedRefreshToken = localStorage.getItem('admin_refresh_token')
|
||||
console.log('Token saved verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT saved!')
|
||||
console.log('RefreshToken saved verification:', savedRefreshToken ? `Token exists, length: ${savedRefreshToken.length}` : 'RefreshToken NOT saved!')
|
||||
|
||||
if (!savedToken) {
|
||||
console.error('CRITICAL: Token was not saved to localStorage!')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ 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 type { Question } from '@/types/questions'
|
||||
import { questionFromJson, questionToJson } from '@/types/questions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -36,6 +38,8 @@ import {
|
|||
import { Label } from '@/components/ui/label'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
|
||||
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
|
||||
|
||||
export default function TestsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -45,6 +49,11 @@ export default function TestsPage() {
|
|||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [testToDelete, setTestToDelete] = useState<TestDto | null>(null)
|
||||
const [isQuestionDialogOpen, setIsQuestionDialogOpen] = useState(false)
|
||||
const [editingQuestion, setEditingQuestion] = useState<{
|
||||
question: Question | null
|
||||
index: number
|
||||
} | null>(null)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
|
|
@ -54,7 +63,7 @@ export default function TestsPage() {
|
|||
version: '',
|
||||
time: '',
|
||||
timeSubtitle: '',
|
||||
questions: [] as TestDto['questions'],
|
||||
questions: [] as Question[],
|
||||
})
|
||||
|
||||
const limit = 20
|
||||
|
|
@ -141,6 +150,17 @@ export default function TestsPage() {
|
|||
// Load full test data if we only have preview
|
||||
const fullTest = test.id ? await testsApi.getTest(test.id) : test
|
||||
setSelectedTest(fullTest)
|
||||
|
||||
// Преобразуем вопросы из JSON в Question объекты
|
||||
const questions: Question[] = (fullTest.questions || []).map((q: any) => {
|
||||
try {
|
||||
return questionFromJson(q)
|
||||
} catch (e) {
|
||||
console.error('Error parsing question:', e, q)
|
||||
return null
|
||||
}
|
||||
}).filter((q): q is Question => q !== null)
|
||||
|
||||
setFormData({
|
||||
name: fullTest.name || '',
|
||||
color: fullTest.color || '',
|
||||
|
|
@ -148,7 +168,7 @@ export default function TestsPage() {
|
|||
version: fullTest.version || '',
|
||||
time: fullTest.time || '',
|
||||
timeSubtitle: fullTest.timeSubtitle || '',
|
||||
questions: fullTest.questions || [],
|
||||
questions,
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
} catch (error) {
|
||||
|
|
@ -173,6 +193,9 @@ export default function TestsPage() {
|
|||
return
|
||||
}
|
||||
|
||||
// Преобразуем вопросы в JSON формат для отправки на бэкенд
|
||||
const questionsJson = formData.questions.map((q) => questionToJson(q))
|
||||
|
||||
const testData: TestDto = {
|
||||
id: selectedTest?.id,
|
||||
name: formData.name.trim(),
|
||||
|
|
@ -181,7 +204,7 @@ export default function TestsPage() {
|
|||
version: formData.version.trim() || undefined,
|
||||
time: formData.time.trim() || undefined,
|
||||
timeSubtitle: formData.timeSubtitle.trim() || undefined,
|
||||
questions: formData.questions,
|
||||
questions: questionsJson as any,
|
||||
}
|
||||
|
||||
if (selectedTest) {
|
||||
|
|
@ -300,7 +323,11 @@ export default function TestsPage() {
|
|||
<TableRow key={test.id || Math.random()}>
|
||||
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
|
||||
<TableCell className="font-medium">{test.name}</TableCell>
|
||||
<TableCell>{test.questions?.length || 0}</TableCell>
|
||||
<TableCell>
|
||||
{typeof test.questions === 'number'
|
||||
? test.questions
|
||||
: test.questions?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell>{test.version || 'N/A'}</TableCell>
|
||||
<TableCell>{test.time || 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -365,7 +392,7 @@ export default function TestsPage() {
|
|||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-6xl max-h-[95vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedTest ? 'Edit Test' : 'Create New Test'}
|
||||
|
|
@ -439,17 +466,29 @@ export default function TestsPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Questions</Label>
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formData.questions.length} question(s) in this test
|
||||
</p>
|
||||
{formData.questions.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Questions are managed separately. After creating the test, you can add questions through the backend API.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<TestQuestionsManager
|
||||
questions={formData.questions}
|
||||
onChange={(questions) =>
|
||||
setFormData((prev) => ({ ...prev, questions }))
|
||||
}
|
||||
onEdit={(question, index) => {
|
||||
setEditingQuestion({ question, index })
|
||||
setIsQuestionDialogOpen(true)
|
||||
}}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingQuestion({ question: null, index: -1 })
|
||||
setIsQuestionDialogOpen(true)
|
||||
}}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Question
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
|
@ -485,6 +524,31 @@ export default function TestsPage() {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Question Editor Dialog */}
|
||||
<QuestionEditorDialog
|
||||
open={isQuestionDialogOpen}
|
||||
question={editingQuestion?.question || null}
|
||||
onSave={(question) => {
|
||||
if (editingQuestion) {
|
||||
const newQuestions = [...formData.questions]
|
||||
if (editingQuestion.index >= 0) {
|
||||
// Редактирование существующего вопроса
|
||||
newQuestions[editingQuestion.index] = question
|
||||
} else {
|
||||
// Добавление нового вопроса
|
||||
newQuestions.push(question)
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, questions: newQuestions }))
|
||||
}
|
||||
setIsQuestionDialogOpen(false)
|
||||
setEditingQuestion(null)
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuestionDialogOpen(false)
|
||||
setEditingQuestion(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,51 +5,90 @@ import type { UserDto } from '@/types/models'
|
|||
interface AuthState {
|
||||
user: UserDto | null
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
expiresIn: number | null
|
||||
tokenExpiresAt: number | null // timestamp when token expires
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
login: (token: string, user: UserDto) => void
|
||||
login: (token: string, refreshToken: string | null, expiresIn: number | null, user: UserDto) => void
|
||||
updateToken: (token: string, refreshToken: string, expiresIn: number) => void
|
||||
logout: () => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
shouldRefreshToken: () => boolean
|
||||
}
|
||||
|
||||
type AuthStore = AuthState & AuthActions
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
// State
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
expiresIn: null,
|
||||
tokenExpiresAt: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// Actions
|
||||
login: (token: string, user: UserDto) => {
|
||||
login: (token: string, refreshToken: string | null, expiresIn: number | null, user: UserDto) => {
|
||||
console.log('[AuthStore] Saving token to localStorage, length:', token.length)
|
||||
localStorage.setItem('admin_token', token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('admin_refresh_token', refreshToken)
|
||||
}
|
||||
|
||||
// Calculate expiration time (refresh 5 minutes before expiration)
|
||||
const tokenExpiresAt = expiresIn
|
||||
? Date.now() + (expiresIn - 300) * 1000 // 5 minutes buffer
|
||||
: null
|
||||
|
||||
// Verify it was saved
|
||||
const savedToken = localStorage.getItem('admin_token')
|
||||
console.log('[AuthStore] Token saved, verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT found!')
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
refreshToken: refreshToken ?? null,
|
||||
expiresIn: expiresIn ?? null,
|
||||
tokenExpiresAt,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
updateToken: (token: string, refreshToken: string, expiresIn: number) => {
|
||||
console.log('[AuthStore] Updating token')
|
||||
localStorage.setItem('admin_token', token)
|
||||
localStorage.setItem('admin_refresh_token', refreshToken)
|
||||
|
||||
const tokenExpiresAt = Date.now() + (expiresIn - 300) * 1000 // 5 minutes buffer
|
||||
|
||||
set({
|
||||
token,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
tokenExpiresAt,
|
||||
})
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
expiresIn: null,
|
||||
tokenExpiresAt: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
|
@ -63,14 +102,45 @@ export const useAuthStore = create<AuthStore>()(
|
|||
setError: (error: string | null) => {
|
||||
set({ error, isLoading: false })
|
||||
},
|
||||
|
||||
shouldRefreshToken: () => {
|
||||
const state = get()
|
||||
if (!state.refreshToken || !state.token) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we have expiresIn but no tokenExpiresAt, calculate it
|
||||
if (state.expiresIn && !state.tokenExpiresAt) {
|
||||
const calculatedExpiresAt = Date.now() + (state.expiresIn - 300) * 1000
|
||||
set({ tokenExpiresAt: calculatedExpiresAt })
|
||||
return Date.now() >= calculatedExpiresAt
|
||||
}
|
||||
|
||||
if (!state.tokenExpiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Refresh if token expires in less than 5 minutes
|
||||
return Date.now() >= state.tokenExpiresAt
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'admin-auth',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
expiresIn: state.expiresIn,
|
||||
tokenExpiresAt: state.tokenExpiresAt,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// After rehydration, recalculate tokenExpiresAt if needed
|
||||
if (state && state.expiresIn && !state.tokenExpiresAt) {
|
||||
const tokenExpiresAt = Date.now() + (state.expiresIn - 300) * 1000
|
||||
state.tokenExpiresAt = tokenExpiresAt
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -129,10 +129,20 @@ export interface AuthRequest {
|
|||
export interface AuthResponse {
|
||||
success?: boolean
|
||||
token: string
|
||||
refreshToken?: string
|
||||
expiresIn?: number
|
||||
user: UserDto
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
success: boolean
|
||||
token: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CodeRequest {
|
||||
code: string
|
||||
}
|
||||
|
|
@ -159,11 +169,9 @@ export interface CodeStatusResponse {
|
|||
}
|
||||
|
||||
// Test types
|
||||
export interface TestQuestion {
|
||||
id?: string
|
||||
questionType: string
|
||||
body: unknown // JSON structure varies by question type
|
||||
}
|
||||
import type { Question } from './questions'
|
||||
|
||||
export type TestQuestion = Question
|
||||
|
||||
export interface TestDto {
|
||||
id?: string
|
||||
|
|
@ -173,6 +181,6 @@ export interface TestDto {
|
|||
version?: string
|
||||
time?: string
|
||||
timeSubtitle?: string
|
||||
questions: TestQuestion[]
|
||||
questions: Question[]
|
||||
statistics?: unknown
|
||||
}
|
||||
|
|
|
|||
116
mnemo_cards_admin/src/types/questions.ts
Normal file
116
mnemo_cards_admin/src/types/questions.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
export enum QuestionType {
|
||||
SIMPLE = 'simple',
|
||||
INPUT_BUTTONS = 'input_buttons',
|
||||
}
|
||||
|
||||
// Соответствует полю options в БД
|
||||
export interface TestButton {
|
||||
id: string
|
||||
text?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
// Базовый интерфейс вопроса
|
||||
export interface BaseQuestion {
|
||||
id?: string
|
||||
questionType: QuestionType
|
||||
orderIndex?: number
|
||||
|
||||
// Ключевые поля (хранятся отдельно в БД)
|
||||
word: string // TestQuestions.word
|
||||
answer: string // TestQuestions.answer
|
||||
options: TestButton[] // TestQuestions.options (JSON)
|
||||
|
||||
// UI данные (хранятся в TestQuestions.uiData как JSON)
|
||||
image?: string
|
||||
text?: string
|
||||
audio?: string
|
||||
}
|
||||
|
||||
export interface SimpleQuestion extends BaseQuestion {
|
||||
questionType: QuestionType.SIMPLE
|
||||
}
|
||||
|
||||
export interface InputButtonsQuestion extends BaseQuestion {
|
||||
questionType: QuestionType.INPUT_BUTTONS
|
||||
template: string // Хранится в uiData
|
||||
}
|
||||
|
||||
export type Question = SimpleQuestion | InputButtonsQuestion
|
||||
|
||||
// Хелпер для преобразования в/из БД формата
|
||||
export interface QuestionDbFormat {
|
||||
id?: string
|
||||
testId: string
|
||||
orderIndex: number
|
||||
questionType: string
|
||||
word: string
|
||||
answer: string
|
||||
options: string // JSON string
|
||||
uiData: string // JSON string
|
||||
}
|
||||
|
||||
// Хелперы для работы с вопросами
|
||||
export function isSimpleQuestion(question: Question): question is SimpleQuestion {
|
||||
return question.questionType === QuestionType.SIMPLE
|
||||
}
|
||||
|
||||
export function isInputButtonsQuestion(
|
||||
question: Question,
|
||||
): question is InputButtonsQuestion {
|
||||
return question.questionType === QuestionType.INPUT_BUTTONS
|
||||
}
|
||||
|
||||
// Создать вопрос из JSON (как приходит с бэкенда)
|
||||
export function questionFromJson(json: any): Question {
|
||||
const base: BaseQuestion = {
|
||||
id: json.id,
|
||||
questionType: json.questionType as QuestionType,
|
||||
orderIndex: json.orderIndex,
|
||||
word: json.word || '',
|
||||
answer: json.answer || '',
|
||||
options: json.buttons || json.options || [],
|
||||
image: json.image,
|
||||
text: json.text,
|
||||
audio: json.audio,
|
||||
}
|
||||
|
||||
if (json.questionType === QuestionType.INPUT_BUTTONS) {
|
||||
return {
|
||||
...base,
|
||||
questionType: QuestionType.INPUT_BUTTONS,
|
||||
template: json.template || '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
questionType: QuestionType.SIMPLE,
|
||||
}
|
||||
}
|
||||
|
||||
// Преобразовать вопрос в JSON для отправки на бэкенд
|
||||
export function questionToJson(question: Question): any {
|
||||
const base: any = {
|
||||
questionType: question.questionType,
|
||||
word: question.word,
|
||||
answer: question.answer,
|
||||
buttons: question.options,
|
||||
}
|
||||
|
||||
if (question.id) {
|
||||
base.id = question.id
|
||||
}
|
||||
|
||||
// UI данные
|
||||
if (question.image) base.image = question.image
|
||||
if (question.text) base.text = question.text
|
||||
if (question.audio) base.audio = question.audio
|
||||
|
||||
// Специфичные для типа поля
|
||||
if (isInputButtonsQuestion(question)) {
|
||||
base.template = question.template
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
|
@ -164,6 +164,8 @@ class AdminAuthApiV2 {
|
|||
return _json({
|
||||
'success': true,
|
||||
'token': tokens.accessToken,
|
||||
'refreshToken': tokens.refreshToken,
|
||||
'expiresIn': tokens.expiresIn,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'name': user.name,
|
||||
|
|
@ -266,6 +268,91 @@ class AdminAuthApiV2 {
|
|||
}
|
||||
}
|
||||
|
||||
/// POST /api/v2/admin/auth/refresh
|
||||
/// Refresh access token using refresh token
|
||||
/// Requires admin privileges
|
||||
@Route.post('/admin/auth/refresh')
|
||||
Future<Response> refreshToken(Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
if (body.isEmpty) {
|
||||
return _json(
|
||||
{
|
||||
'success': false,
|
||||
'message': 'Request body is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
final jsonData = jsonDecode(body) as Map<String, dynamic>;
|
||||
final refreshToken = jsonData['refreshToken'] as String?;
|
||||
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
return _json(
|
||||
{
|
||||
'success': false,
|
||||
'message': 'refreshToken is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify and extract user from refresh token
|
||||
final userIdStr = await _jwtService.verifyRefreshToken(refreshToken);
|
||||
if (userIdStr == null || userIdStr.isEmpty) {
|
||||
return _json(
|
||||
{
|
||||
'success': false,
|
||||
'message': 'Invalid or expired refresh token',
|
||||
},
|
||||
statusCode: 401,
|
||||
);
|
||||
}
|
||||
|
||||
// Get user
|
||||
final user = await _userManager.fetchUser(userIdStr);
|
||||
if (user == null) {
|
||||
return _json(
|
||||
{
|
||||
'success': false,
|
||||
'message': 'User not found',
|
||||
},
|
||||
statusCode: 401,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify admin status
|
||||
if (!user.admin) {
|
||||
return _json(
|
||||
{
|
||||
'success': false,
|
||||
'message': 'Access denied: not an admin',
|
||||
},
|
||||
statusCode: 403,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
final tokens = await _jwtService.generateTokens(user);
|
||||
|
||||
return _json({
|
||||
'success': true,
|
||||
'token': tokens.accessToken,
|
||||
'refreshToken': tokens.refreshToken,
|
||||
'expiresIn': tokens.expiresIn,
|
||||
});
|
||||
} catch (e) {
|
||||
return _json(
|
||||
{
|
||||
'success': false,
|
||||
'message': 'Internal server error: $e',
|
||||
},
|
||||
statusCode: 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of admin Telegram IDs from environment variable or file
|
||||
Future<List<String>> _getAdminIds() async {
|
||||
return AdminIdsService.getAdminIds();
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@ Router _$AdminAuthApiV2Router(AdminAuthApiV2 service) {
|
|||
router.add('POST', r'/admin/auth/verify-code', service.verifyCode);
|
||||
router.add('GET', r'/admin/auth/code-status/<code>', service.getCodeStatus);
|
||||
router.add('GET', r'/admin/auth/me', service.getCurrentUser);
|
||||
router.add('POST', r'/admin/auth/refresh', service.refreshToken);
|
||||
return router;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -434,6 +434,11 @@ class AdminCardsApiV2 {
|
|||
@Route.delete('/admin/cards/<cardId>')
|
||||
Future<Response> deleteCard(Request request, String cardId) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (cardId.isEmpty) {
|
||||
return Response.badRequest(
|
||||
body: json.encode({
|
||||
|
|
|
|||
|
|
@ -166,14 +166,102 @@ class AdminTestsApiV2 {
|
|||
);
|
||||
}
|
||||
|
||||
// Get packId for the test to build image URLs
|
||||
final packId = await _db.testDao.getPackIdForTest(testId);
|
||||
|
||||
// Get questions
|
||||
final questions = await _db.testDao.getTestQuestions(testId);
|
||||
final questionsList = questions.map((q) {
|
||||
final body = json.decode(q.body) as Map<String, dynamic>;
|
||||
return AbstractTestQuestion.fromJson({
|
||||
// Новая структура: собираем вопрос из отдельных полей
|
||||
Map<String, dynamic> questionJson = {
|
||||
'questionType': q.questionType,
|
||||
...body,
|
||||
});
|
||||
'id': q.id,
|
||||
'word': q.word,
|
||||
};
|
||||
|
||||
// Парсим options (JSON array кнопок)
|
||||
try {
|
||||
final options = json.decode(q.options) as List<dynamic>;
|
||||
questionJson['buttons'] = options;
|
||||
} catch (e) {
|
||||
questionJson['buttons'] = [];
|
||||
}
|
||||
|
||||
// Добавляем answer
|
||||
questionJson['answer'] = q.answer;
|
||||
|
||||
// Парсим uiData (image, text, audio, template)
|
||||
try {
|
||||
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
|
||||
questionJson.addAll(uiData);
|
||||
} catch (e) {
|
||||
// Если uiData пустой или невалидный, игнорируем
|
||||
}
|
||||
|
||||
return AbstractTestQuestion.fromJson(questionJson);
|
||||
}).toList();
|
||||
|
||||
// Helper function to convert image ID to URL
|
||||
String? _convertImageToUrl(String? imageValue, String? packId) {
|
||||
if (imageValue == null || packId == null) return imageValue;
|
||||
|
||||
// If it's already a proper URL, return as is
|
||||
if (imageValue.startsWith('http://') ||
|
||||
imageValue.startsWith('https://') ||
|
||||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
|
||||
return imageValue;
|
||||
}
|
||||
|
||||
// If it's base64 data URL, extract card ID if possible
|
||||
// Format: /api/v2/packs/{packId}/cards/{base64}/image or just base64
|
||||
if (imageValue.contains('/cards/')) {
|
||||
// Extract card ID from path like /api/v2/packs/{packId}/cards/{cardId}/image
|
||||
final parts = imageValue.split('/cards/');
|
||||
if (parts.length == 2) {
|
||||
final cardIdPart = parts[1].split('/')[0];
|
||||
// If it's a valid UUID format, use it; otherwise it might be base64
|
||||
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(cardIdPart)) {
|
||||
return '/api/v2/packs/$packId/cards/$cardIdPart/image';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it looks like a UUID (card ID), convert to URL
|
||||
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
|
||||
return '/api/v2/packs/$packId/cards/$imageValue/image';
|
||||
}
|
||||
|
||||
// Otherwise, assume it's already a card ID and convert
|
||||
// This handles cases where the value is stored as card ID
|
||||
return '/api/v2/packs/$packId/cards/$imageValue/image';
|
||||
}
|
||||
|
||||
// Convert image IDs to URLs in questions
|
||||
final questionsWithUrls = questionsList.map((q) {
|
||||
final questionJson = q.toJson();
|
||||
|
||||
// Convert image in question itself
|
||||
if (questionJson['image'] != null) {
|
||||
questionJson['image'] = _convertImageToUrl(
|
||||
questionJson['image'] as String?,
|
||||
packId,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert images in buttons
|
||||
if (questionJson['buttons'] != null) {
|
||||
final buttons = questionJson['buttons'] as List<dynamic>;
|
||||
for (final button in buttons) {
|
||||
if (button is Map<String, dynamic> && button['image'] != null) {
|
||||
button['image'] = _convertImageToUrl(
|
||||
button['image'] as String?,
|
||||
packId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return questionJson;
|
||||
}).toList();
|
||||
|
||||
return _json({
|
||||
|
|
@ -184,7 +272,7 @@ class AdminTestsApiV2 {
|
|||
'version': test.version ?? '1.0',
|
||||
'time': test.time,
|
||||
'timeSubtitle': test.timeSubtitle,
|
||||
'questions': questionsList.map((q) => q.toJson()).toList(),
|
||||
'questions': questionsWithUrls,
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error in getTest: $e\n$s');
|
||||
|
|
@ -281,16 +369,32 @@ class AdminTestsApiV2 {
|
|||
await _db.testDao.softDeleteTestQuestion(q.id);
|
||||
}
|
||||
// Add new questions
|
||||
int orderIndex = 0;
|
||||
for (final q in questions) {
|
||||
final questionJson = q as Map<String, dynamic>;
|
||||
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
|
||||
final questionBody = Map<String, dynamic>.from(questionJson);
|
||||
questionBody.remove('questionType');
|
||||
final questionType = questionJson['questionType'] as String? ?? 'simple';
|
||||
|
||||
// Извлекаем ключевые поля
|
||||
final word = questionJson['word'] as String? ?? '';
|
||||
final answer = questionJson['answer'] as String? ?? '';
|
||||
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
|
||||
|
||||
// UI данные (image, text, audio, template)
|
||||
final uiData = <String, dynamic>{};
|
||||
if (questionJson['image'] != null) uiData['image'] = questionJson['image'];
|
||||
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
|
||||
if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio'];
|
||||
if (questionJson['template'] != null) uiData['template'] = questionJson['template'];
|
||||
|
||||
await _db.testDao.createTestQuestion(
|
||||
TestQuestionsCompanion.insert(
|
||||
testId: testId,
|
||||
orderIndex: orderIndex++,
|
||||
questionType: questionType,
|
||||
body: jsonEncode(questionBody),
|
||||
word: word,
|
||||
answer: answer,
|
||||
options: jsonEncode(buttons),
|
||||
uiData: jsonEncode(uiData),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -332,16 +436,32 @@ class AdminTestsApiV2 {
|
|||
|
||||
// Add questions if provided
|
||||
if (questions != null) {
|
||||
int orderIndex = 0;
|
||||
for (final q in questions) {
|
||||
final questionJson = q as Map<String, dynamic>;
|
||||
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
|
||||
final questionBody = Map<String, dynamic>.from(questionJson);
|
||||
questionBody.remove('questionType');
|
||||
final questionType = questionJson['questionType'] as String? ?? 'simple';
|
||||
|
||||
// Извлекаем ключевые поля
|
||||
final word = questionJson['word'] as String? ?? '';
|
||||
final answer = questionJson['answer'] as String? ?? '';
|
||||
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
|
||||
|
||||
// UI данные (image, text, audio, template)
|
||||
final uiData = <String, dynamic>{};
|
||||
if (questionJson['image'] != null) uiData['image'] = questionJson['image'];
|
||||
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
|
||||
if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio'];
|
||||
if (questionJson['template'] != null) uiData['template'] = questionJson['template'];
|
||||
|
||||
await _db.testDao.createTestQuestion(
|
||||
TestQuestionsCompanion.insert(
|
||||
testId: newTest,
|
||||
orderIndex: orderIndex++,
|
||||
questionType: questionType,
|
||||
body: jsonEncode(questionBody),
|
||||
word: word,
|
||||
answer: answer,
|
||||
options: jsonEncode(buttons),
|
||||
uiData: jsonEncode(uiData),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,6 +430,73 @@ class PacksApiV2 {
|
|||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/packs/{packId}/cards/{cardId}/imageBack
|
||||
/// Get card back image
|
||||
/// Returns PNG image file
|
||||
///
|
||||
/// Images are accessible for enabled packs even without authentication
|
||||
/// to allow image preview in public pack listings
|
||||
@Route.get('/packs/<packId>/cards/<cardId>/imageBack')
|
||||
@OpenApiRouteHttp()
|
||||
Future<Response> getCardImageBack(
|
||||
Request request,
|
||||
String packId,
|
||||
String cardId,
|
||||
) async {
|
||||
try {
|
||||
if (cardId.isEmpty) {
|
||||
return _badRequest('Invalid card ID');
|
||||
}
|
||||
|
||||
if (packId.isEmpty) {
|
||||
return _badRequest('Invalid pack ID');
|
||||
}
|
||||
|
||||
// Check if pack exists and is enabled
|
||||
// We allow access to images for enabled packs even without auth
|
||||
// to support image previews in public listings
|
||||
final pack = await _db.packDao.getPackById(packId);
|
||||
if (pack == null || !pack.enabled) {
|
||||
return _notFound('Pack not found or not enabled');
|
||||
}
|
||||
|
||||
// Get card and verify it belongs to the pack
|
||||
final card = await _db.packDao.getCardById(cardId);
|
||||
if (card == null || card.imageBack == null || card.imageBack!.isEmpty) {
|
||||
return _notFound('Card or back image not found');
|
||||
}
|
||||
|
||||
// Verify card belongs to this pack
|
||||
final packCards = await _db.packDao.getPackCards(packId);
|
||||
final belongsToPack = packCards.any((c) => c.id == cardId);
|
||||
if (!belongsToPack) {
|
||||
return _notFound('Card does not belong to this pack');
|
||||
}
|
||||
|
||||
// Get image bytes - card.imageBack is already base64 or path
|
||||
// For now, assume it's base64 encoded or needs to be loaded
|
||||
final imageBase64 = card.imageBack!;
|
||||
|
||||
if (imageBase64.isEmpty) {
|
||||
return _notFound('Back image not found');
|
||||
}
|
||||
|
||||
// Decode base64 and return as image
|
||||
final imageBytes = base64Decode(imageBase64);
|
||||
|
||||
return Response.ok(
|
||||
imageBytes,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
|
||||
},
|
||||
);
|
||||
} catch (e, s) {
|
||||
print('Error fetching card back image: $e\n$s');
|
||||
return _internalServerError('Error loading back image');
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/packs/{packId}/cards/{cardId}/voices
|
||||
/// Get card voices metadata
|
||||
/// Returns JSON list of VoiceDto
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ Router _$PacksApiV2Router(PacksApiV2 service) {
|
|||
r'/packs/<packId>/cards/<cardId>/image',
|
||||
service.getCardImage,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/cards/<cardId>/imageBack',
|
||||
service.getCardImageBack,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/cards/<cardId>/voices',
|
||||
|
|
|
|||
|
|
@ -88,9 +88,11 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
|
|||
|
||||
// ==================== GameCards ====================
|
||||
|
||||
/// Получить карточку по ID
|
||||
/// Получить карточку по ID (только активные)
|
||||
Future<GameCard?> getCardById(String id) {
|
||||
return (select(gameCards)..where((c) => c.id.equals(id))).getSingleOrNull();
|
||||
return (select(gameCards)
|
||||
..where((c) => c.id.equals(id) & c.isDeleted.equals(false))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить паки для карточки
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:drift/drift.dart';
|
|||
import 'package:drift_postgres/drift_postgres.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/tests.dart';
|
||||
import '../tables/packs.dart';
|
||||
|
||||
part 'test_dao.g.dart';
|
||||
|
||||
|
|
@ -56,6 +55,7 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
|||
return (update(tests)..where((t) => t.id.equals(testId)))
|
||||
.write(TestsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||
));
|
||||
}
|
||||
|
|
@ -70,13 +70,23 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
|||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Получить packId для теста
|
||||
Future<String?> getPackIdForTest(String testId) async {
|
||||
final relation = await (select(testPackRelations)
|
||||
..where((tpr) => tpr.testId.equals(testId))
|
||||
..limit(1)
|
||||
).getSingleOrNull();
|
||||
return relation?.packId;
|
||||
}
|
||||
|
||||
// ==================== TestQuestions ====================
|
||||
|
||||
/// Получить вопросы теста (только активные)
|
||||
/// Получить вопросы теста (только активные, отсортированные по orderIndex)
|
||||
Future<List<TestQuestion>> getTestQuestions(String testId) {
|
||||
return (select(testQuestions)
|
||||
..where((tq) => tq.testId.equals(testId) & tq.isDeleted.equals(false))
|
||||
..orderBy([(tq) => OrderingTerm(expression: tq.orderIndex)])
|
||||
).get();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
|||
import 'package:drift_postgres/drift_postgres.dart';
|
||||
import 'package:postgres/postgres.dart' as pg;
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
// Импорт конвертеров (нужен для генерации кода)
|
||||
import 'converters.dart';
|
||||
|
|
@ -122,7 +123,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
AppDatabase(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
/// Factory для подключения к PostgreSQL
|
||||
static AppDatabase connect({
|
||||
|
|
@ -177,10 +178,10 @@ class AppDatabase extends _$AppDatabase {
|
|||
onUpgrade: (Migrator m, int from, int to) async {
|
||||
print('Migrating database from version $from to $to');
|
||||
|
||||
// Миграции при обновлении схемы
|
||||
// if (from < 2) {
|
||||
// await m.addColumn(users, users.phoneNumber);
|
||||
// }
|
||||
// Миграция с версии 1 на 2: обновление структуры тестов
|
||||
if (from < 2) {
|
||||
await _migrateToV2(m);
|
||||
}
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
print('Opening database connection...');
|
||||
|
|
@ -251,4 +252,63 @@ class AppDatabase extends _$AppDatabase {
|
|||
|
||||
print('Indexes created successfully');
|
||||
}
|
||||
|
||||
/// Миграция с версии 1 на версию 2
|
||||
/// Обновление структуры тестов: добавление новых колонок и очистка старых данных
|
||||
Future<void> _migrateToV2(Migrator m) async {
|
||||
print('Starting migration to v2: updating test questions and statistics...');
|
||||
|
||||
try {
|
||||
// 1. Добавляем новые колонки в test_questions
|
||||
print('Adding new columns to test_questions...');
|
||||
await customStatement(
|
||||
'ALTER TABLE test_questions '
|
||||
'ADD COLUMN IF NOT EXISTS order_index INTEGER DEFAULT 0, '
|
||||
'ADD COLUMN IF NOT EXISTS word TEXT DEFAULT \'\', '
|
||||
'ADD COLUMN IF NOT EXISTS answer TEXT DEFAULT \'\', '
|
||||
'ADD COLUMN IF NOT EXISTS options TEXT DEFAULT \'[]\', '
|
||||
'ADD COLUMN IF NOT EXISTS ui_data TEXT DEFAULT \'{}\'',
|
||||
);
|
||||
|
||||
// 2. Удаляем все старые вопросы (soft delete)
|
||||
print('Soft-deleting all existing test questions...');
|
||||
await customStatement(
|
||||
'UPDATE test_questions SET is_deleted = TRUE, deleted_at = NOW() WHERE is_deleted = FALSE',
|
||||
);
|
||||
print('All old questions marked as deleted. Create new questions via admin panel.');
|
||||
|
||||
// 3. Удаляем старую колонку body
|
||||
print('Dropping old body column...');
|
||||
await customStatement('ALTER TABLE test_questions DROP COLUMN IF EXISTS body');
|
||||
|
||||
// 4. Добавляем новые колонки в test_statistics
|
||||
print('Adding new columns to test_statistics...');
|
||||
await customStatement(
|
||||
'ALTER TABLE test_statistics '
|
||||
'ADD COLUMN IF NOT EXISTS total_questions INTEGER DEFAULT 0, '
|
||||
'ADD COLUMN IF NOT EXISTS correct_answers INTEGER DEFAULT 0, '
|
||||
'ADD COLUMN IF NOT EXISTS incorrect_answers INTEGER DEFAULT 0, '
|
||||
'ADD COLUMN IF NOT EXISTS skipped_answers INTEGER DEFAULT 0, '
|
||||
'ADD COLUMN IF NOT EXISTS score_percentage REAL DEFAULT 0.0, '
|
||||
'ADD COLUMN IF NOT EXISTS time_spent_seconds INTEGER, '
|
||||
'ADD COLUMN IF NOT EXISTS question_results TEXT DEFAULT \'[]\', '
|
||||
'ADD COLUMN IF NOT EXISTS metadata TEXT DEFAULT \'{}\'',
|
||||
);
|
||||
|
||||
// 5. Удаляем всю старую статистику (она будет создаваться заново)
|
||||
print('Deleting old test statistics...');
|
||||
await customStatement('DELETE FROM test_statistics');
|
||||
print('All old statistics deleted. New statistics will be collected automatically.');
|
||||
|
||||
// 6. Удаляем старую колонку results
|
||||
print('Dropping old results column...');
|
||||
await customStatement('ALTER TABLE test_statistics DROP COLUMN IF EXISTS results');
|
||||
|
||||
print('Migration to v2 completed successfully!');
|
||||
} catch (e, stackTrace) {
|
||||
print('Error during migration to v2: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -39,9 +39,26 @@ class TestQuestions extends Table {
|
|||
TextColumn get testId => text()
|
||||
.references(Tests, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Порядок вопроса в тесте
|
||||
IntColumn get orderIndex => integer()
|
||||
.withDefault(const Constant(0))();
|
||||
|
||||
// Тип вопроса (enum as string)
|
||||
TextColumn get questionType => text()();
|
||||
TextColumn get body => text()();
|
||||
|
||||
// Ключевые поля вынесены из JSON
|
||||
TextColumn get word => text()(); // Слово/фраза для изучения
|
||||
TextColumn get answer => text()(); // Правильный ответ (ID кнопки)
|
||||
|
||||
// Варианты ответов (JSON array кнопок)
|
||||
// [{"id": "btn1", "text": "apple", "image": null}, ...]
|
||||
TextColumn get options => text()
|
||||
.withDefault(const Constant('[]'))();
|
||||
|
||||
// UI данные (image, text, audio, template для input_buttons)
|
||||
// {"image": "url", "text": "Question?", "audio": "url", "template": "___"}
|
||||
TextColumn get uiData => text()
|
||||
.withDefault(const Constant('{}'))();
|
||||
|
||||
// Audit
|
||||
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
|
||||
|
|
@ -78,11 +95,34 @@ class TestStatistics extends Table {
|
|||
TextColumn get testId => text()
|
||||
.references(Tests, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Результаты (JSON)
|
||||
TextColumn get results => text()
|
||||
.withDefault(const Constant('{}'))
|
||||
.map(const JsonMapConverter())();
|
||||
// Сводные метрики (вынесены для быстрого доступа и аналитики)
|
||||
IntColumn get totalQuestions => integer()
|
||||
.withDefault(const Constant(0))();
|
||||
IntColumn get correctAnswers => integer()
|
||||
.withDefault(const Constant(0))();
|
||||
IntColumn get incorrectAnswers => integer()
|
||||
.withDefault(const Constant(0))();
|
||||
IntColumn get skippedAnswers => integer()
|
||||
.withDefault(const Constant(0))();
|
||||
|
||||
// Процент правильных ответов (0-100)
|
||||
RealColumn get scorePercentage => real()
|
||||
.withDefault(const Constant(0.0))();
|
||||
|
||||
// Время прохождения в секундах
|
||||
IntColumn get timeSpentSeconds => integer()
|
||||
.nullable()();
|
||||
|
||||
// Детальные результаты по каждому вопросу (JSON)
|
||||
// [{"questionId": "123", "correct": true, "timeSpent": 5, "answer": "a1"}]
|
||||
TextColumn get questionResults => text()
|
||||
.withDefault(const Constant('[]'))();
|
||||
|
||||
// Дополнительные данные (по типам вопросов, streak и т.д.)
|
||||
TextColumn get metadata => text()
|
||||
.withDefault(const Constant('{}'))();
|
||||
|
||||
// Когда завершен тест
|
||||
Column<PgDateTime> get completedAt => customType(PgTypes.timestampWithTimezone)
|
||||
.withDefault(now())();
|
||||
|
||||
|
|
|
|||
|
|
@ -588,6 +588,137 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('PacksApiV2 - Get Card Image Back', () {
|
||||
test('should return 404 for card without imageBack', () async {
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/10/cards/1/imageBack',
|
||||
user: testUser,
|
||||
);
|
||||
|
||||
final response = await packsApiV2.getCardImageBack(request, '10', '1');
|
||||
final responseBody = jsonDecode(await response.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, equals(404));
|
||||
expect(responseBody['error'], equals('Not Found'));
|
||||
});
|
||||
|
||||
test('should return 404 for non-existent card', () async {
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/10/cards/999/imageBack',
|
||||
user: testUser,
|
||||
);
|
||||
|
||||
final response = await packsApiV2.getCardImageBack(request, '10', '999');
|
||||
final responseBody = jsonDecode(await response.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, equals(404));
|
||||
expect(responseBody['error'], equals('Not Found'));
|
||||
});
|
||||
|
||||
test('should return 404 for non-existent pack', () async {
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/999/cards/1/imageBack',
|
||||
user: testUser,
|
||||
);
|
||||
|
||||
final response = await packsApiV2.getCardImageBack(request, '999', '1');
|
||||
final responseBody = jsonDecode(await response.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, equals(404));
|
||||
expect(responseBody['error'], equals('Not Found'));
|
||||
});
|
||||
|
||||
test('should return 400 for invalid card ID', () async {
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/10/cards/invalid/imageBack',
|
||||
user: testUser,
|
||||
);
|
||||
|
||||
final response = await packsApiV2.getCardImageBack(
|
||||
request,
|
||||
'10',
|
||||
'invalid',
|
||||
);
|
||||
final responseBody = jsonDecode(await response.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, equals(400));
|
||||
expect(responseBody['error'], equals('Bad Request'));
|
||||
});
|
||||
|
||||
test('should return 400 for invalid pack ID', () async {
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/invalid/cards/1/imageBack',
|
||||
user: testUser,
|
||||
);
|
||||
|
||||
final response = await packsApiV2.getCardImageBack(
|
||||
request,
|
||||
'invalid',
|
||||
'1',
|
||||
);
|
||||
final responseBody = jsonDecode(await response.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, equals(400));
|
||||
expect(responseBody['error'], equals('Bad Request'));
|
||||
});
|
||||
|
||||
test('should return 404 for disabled pack', () async {
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/99/cards/1/imageBack',
|
||||
user: testUser,
|
||||
);
|
||||
|
||||
final response = await packsApiV2.getCardImageBack(request, '99', '1');
|
||||
final responseBody = jsonDecode(await response.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, equals(404));
|
||||
expect(responseBody['error'], equals('Not Found'));
|
||||
});
|
||||
|
||||
test('should allow access without authentication for enabled packs', () async {
|
||||
// Request without user context (public access)
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/10/cards/1/imageBack',
|
||||
);
|
||||
|
||||
// This should work even without auth because pack 10 is enabled
|
||||
// and images are now accessible for enabled packs
|
||||
final response = await packsApiV2.getCardImageBack(request, '10', '1');
|
||||
|
||||
// Note: This might return 404 if image file doesn't exist,
|
||||
// but it should not return 401 Unauthorized
|
||||
expect(response.statusCode, isNot(equals(401)));
|
||||
});
|
||||
|
||||
test('should return 404 if card does not belong to pack', () async {
|
||||
// Try to get card 3 from pack 10 (card 3 belongs to pack 20)
|
||||
final request = buildRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/packs/10/cards/3/imageBack',
|
||||
);
|
||||
|
||||
final response = await packsApiV2.getCardImageBack(request, '10', '3');
|
||||
final responseBody = jsonDecode(await response.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, equals(404));
|
||||
expect(responseBody['error'], equals('Not Found'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PacksApiV2 - Get Pack Tests', () {
|
||||
test('should return 401 for unauthenticated request', () async {
|
||||
final request = buildRequest(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
import 'dart:io';
|
||||
import 'package:drift_postgres/drift_postgres.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/users.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/packs.dart';
|
||||
|
||||
/// Unit тесты для SoftDeleteMixin
|
||||
///
|
||||
/// Тестирует функциональность soft delete на примере WordStatisticsDao
|
||||
/// Требования:
|
||||
/// - PostgreSQL должен быть запущен на localhost:5432
|
||||
/// - Тестовая БД: mnemo_cards_test
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late String testUserId;
|
||||
late String testCardId;
|
||||
|
||||
setUpAll(() async {
|
||||
// Подключение к тестовой БД
|
||||
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
|
||||
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
|
||||
|
||||
db = AppDatabase.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
await db.migrator.createAll();
|
||||
|
||||
// Создать тестовые данные
|
||||
final user = await db.userDao.createUser(
|
||||
UsersCompanion.insert(
|
||||
externalUserId: 'test_user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: Value('Test User'),
|
||||
email: Value('test@example.com'),
|
||||
),
|
||||
);
|
||||
testUserId = user.id;
|
||||
|
||||
await db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(userId: testUserId),
|
||||
);
|
||||
|
||||
final card = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
image: 'test.png',
|
||||
),
|
||||
);
|
||||
testCardId = card.id;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.delete(db.wordStatistics).go();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await db.delete(db.wordStatistics).go();
|
||||
await db.delete(db.gameCards).go();
|
||||
await db.delete(db.userDatas).go();
|
||||
await db.delete(db.users).go();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('SoftDeleteMixin', () {
|
||||
group('selectActive', () {
|
||||
test('возвращает только активные записи', () async {
|
||||
// Создать несколько записей
|
||||
final stats1 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
final stats2 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
correctAnswers: 3,
|
||||
incorrectAnswers: 1,
|
||||
);
|
||||
|
||||
final card3 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test3',
|
||||
translation: 'тест3',
|
||||
image: 'test3.png',
|
||||
),
|
||||
);
|
||||
final stats3 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card3.id,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
|
||||
// Проверить что все записи активны
|
||||
var activeStats = await db.wordStatisticsDao.selectActive().get();
|
||||
expect(activeStats.length, equals(3));
|
||||
|
||||
// Удалить одну запись (soft delete)
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats2.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
// selectActive должен вернуть только активные
|
||||
activeStats = await db.wordStatisticsDao.selectActive().get();
|
||||
expect(activeStats.length, equals(2));
|
||||
expect(
|
||||
activeStats.map((s) => s.id).toSet(),
|
||||
containsAll([stats1.id, stats3.id]),
|
||||
);
|
||||
expect(
|
||||
activeStats.map((s) => s.id).toSet(),
|
||||
isNot(contains(stats2.id)),
|
||||
);
|
||||
});
|
||||
|
||||
test('не возвращает удаленные записи', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
|
||||
// Удалить запись
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
final activeStats = await db.wordStatisticsDao.selectActive().get();
|
||||
expect(activeStats, isEmpty);
|
||||
});
|
||||
|
||||
test('работает с where условиями', () async {
|
||||
// Создать записи для разных пользователей
|
||||
final user2 = await db.userDao.createUser(
|
||||
UsersCompanion.insert(
|
||||
externalUserId: 'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: Value('Test User 2'),
|
||||
),
|
||||
);
|
||||
await db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(userId: user2.id),
|
||||
);
|
||||
|
||||
final stats1 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
final stats2 = await db.wordStatisticsDao.create(
|
||||
userId: user2.id,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 3,
|
||||
incorrectAnswers: 1,
|
||||
);
|
||||
|
||||
// selectActive с where должен фильтровать и по isDeleted, и по условию
|
||||
final user1Stats = await (db.wordStatisticsDao.selectActive()
|
||||
..where((w) => w.userId.equals(testUserId)))
|
||||
.get();
|
||||
expect(user1Stats.length, equals(1));
|
||||
expect(user1Stats.first.id, equals(stats1.id));
|
||||
|
||||
// Удалить запись пользователя 1
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats1.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
// Теперь selectActive для пользователя 1 должен вернуть пустой список
|
||||
final user1StatsAfterDelete = await (db.wordStatisticsDao.selectActive()
|
||||
..where((w) => w.userId.equals(testUserId)))
|
||||
.get();
|
||||
expect(user1StatsAfterDelete, isEmpty);
|
||||
|
||||
// Но запись пользователя 2 все еще активна
|
||||
final user2Stats = await (db.wordStatisticsDao.selectActive()
|
||||
..where((w) => w.userId.equals(user2.id)))
|
||||
.get();
|
||||
expect(user2Stats.length, equals(1));
|
||||
expect(user2Stats.first.id, equals(stats2.id));
|
||||
});
|
||||
});
|
||||
|
||||
group('getActiveById', () {
|
||||
test('возвращает запись если она активна', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
final result = await db.wordStatisticsDao.getActiveById(stats.id);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.id, equals(stats.id));
|
||||
expect(result.userId, equals(testUserId));
|
||||
expect(result.cardId, equals(testCardId));
|
||||
});
|
||||
|
||||
test('возвращает null если запись не существует', () async {
|
||||
final result = await db.wordStatisticsDao.getActiveById('non_existent_id');
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('возвращает null если запись удалена', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
// Удалить запись
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await db.wordStatisticsDao.getActiveById(stats.id);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('не возвращает удаленные записи даже если ID существует', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
|
||||
// Удалить запись
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
// Проверить что запись физически существует в БД, но getActiveById не возвращает её
|
||||
final allRecords = await db.select(db.wordStatistics).get();
|
||||
expect(allRecords.length, equals(1));
|
||||
expect(allRecords.first.isDeleted, isTrue);
|
||||
|
||||
final activeRecord = await db.wordStatisticsDao.getActiveById(stats.id);
|
||||
expect(activeRecord, isNull);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,485 @@
|
|||
import 'dart:io';
|
||||
import 'package:drift_postgres/drift_postgres.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/users.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/packs.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/relations.dart';
|
||||
|
||||
/// Unit тесты для WordStatisticsDao
|
||||
///
|
||||
/// Требования:
|
||||
/// - PostgreSQL должен быть запущен на localhost:5432
|
||||
/// - Тестовая БД: mnemo_cards_test
|
||||
/// - Пользователь: postgres (или настроить через переменные окружения)
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late String testUserId;
|
||||
late String testCardId;
|
||||
late String testPackId;
|
||||
|
||||
setUpAll(() async {
|
||||
// Подключение к тестовой БД
|
||||
// Можно использовать переменные окружения для настройки
|
||||
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
|
||||
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
|
||||
|
||||
db = AppDatabase.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Создать схему БД
|
||||
await db.migrator.createAll();
|
||||
|
||||
// Создать тестовые данные
|
||||
// 1. Создать пользователя
|
||||
final user = await db.userDao.createUser(
|
||||
UsersCompanion.insert(
|
||||
externalUserId: 'test_user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: Value('Test User'),
|
||||
email: Value('test@example.com'),
|
||||
),
|
||||
);
|
||||
testUserId = user.id;
|
||||
|
||||
// 2. Создать UserData
|
||||
await db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(
|
||||
userId: testUserId,
|
||||
),
|
||||
);
|
||||
|
||||
// 3. Создать пак
|
||||
final pack = await db.packDao.createPack(
|
||||
CardPacksCompanion.insert(
|
||||
title: 'Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
size: 10,
|
||||
),
|
||||
);
|
||||
testPackId = pack.id;
|
||||
|
||||
// 4. Создать карточку
|
||||
final card = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
image: 'test.png',
|
||||
),
|
||||
);
|
||||
testCardId = card.id;
|
||||
|
||||
// 5. Связать карточку с паком
|
||||
await db.packDao.linkCardToPack(testCardId, testPackId);
|
||||
|
||||
// 6. Связать пользователя с паком
|
||||
await db.packDao.linkUserToPack(testUserId, testPackId);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
// Очистить WordStatistics после каждого теста
|
||||
await db.delete(db.wordStatistics).go();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
// Очистить все тестовые данные
|
||||
await db.delete(db.wordStatistics).go();
|
||||
await db.delete(db.cardPackCards).go();
|
||||
await db.delete(db.userPacks).go();
|
||||
await db.delete(db.gameCards).go();
|
||||
await db.delete(db.cardPacks).go();
|
||||
await db.delete(db.userDatas).go();
|
||||
await db.delete(db.users).go();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('WordStatisticsDao', () {
|
||||
group('create', () {
|
||||
test('создает новую запись статистики', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
expect(stats.id, isNotEmpty);
|
||||
expect(stats.userId, equals(testUserId));
|
||||
expect(stats.cardId, equals(testCardId));
|
||||
expect(stats.correctAnswers, equals(5));
|
||||
expect(stats.incorrectAnswers, equals(2));
|
||||
expect(stats.mastery, closeTo(5.0 / 7.0, 0.001));
|
||||
expect(stats.lastReviewed, isNotNull);
|
||||
expect(stats.isDeleted, isFalse);
|
||||
});
|
||||
|
||||
test('рассчитывает mastery корректно', () async {
|
||||
final stats1 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 10,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
expect(stats1.mastery, equals(1.0));
|
||||
|
||||
// Создать другую карточку для второго теста
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
|
||||
final stats2 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
correctAnswers: 0,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
expect(stats2.mastery, equals(0.0));
|
||||
|
||||
final stats3 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: 'card3',
|
||||
correctAnswers: 7,
|
||||
incorrectAnswers: 3,
|
||||
);
|
||||
expect(stats3.mastery, closeTo(0.7, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
group('getByUserAndCard', () {
|
||||
test('возвращает null если запись не существует', () async {
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
'non_existent_card',
|
||||
);
|
||||
expect(stats, isNull);
|
||||
});
|
||||
|
||||
test('возвращает существующую запись', () async {
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 3,
|
||||
incorrectAnswers: 1,
|
||||
);
|
||||
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.userId, equals(testUserId));
|
||||
expect(stats.cardId, equals(testCardId));
|
||||
expect(stats.correctAnswers, equals(3));
|
||||
expect(stats.incorrectAnswers, equals(1));
|
||||
});
|
||||
|
||||
test('не возвращает удаленные записи', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
|
||||
// Soft delete
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('updateStatistics', () {
|
||||
test('обновляет существующую статистику', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
await db.wordStatisticsDao.updateStatistics(
|
||||
id: stats.id,
|
||||
correctAnswers: 8,
|
||||
incorrectAnswers: 3,
|
||||
lastReviewed: DateTime.now(),
|
||||
);
|
||||
|
||||
final updated = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(updated, isNotNull);
|
||||
expect(updated!.correctAnswers, equals(8));
|
||||
expect(updated.incorrectAnswers, equals(3));
|
||||
expect(updated.mastery, closeTo(8.0 / 11.0, 0.001));
|
||||
});
|
||||
|
||||
test('пересчитывает mastery при обновлении', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 1,
|
||||
);
|
||||
|
||||
await db.wordStatisticsDao.updateStatistics(
|
||||
id: stats.id,
|
||||
correctAnswers: 10,
|
||||
incorrectAnswers: 0,
|
||||
lastReviewed: DateTime.now(),
|
||||
);
|
||||
|
||||
final updated = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(updated!.mastery, equals(1.0));
|
||||
});
|
||||
});
|
||||
|
||||
group('getPackStatistics', () {
|
||||
test('возвращает статистику по карточкам пака', () async {
|
||||
// Создать еще одну карточку в том же паке
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
await db.packDao.linkCardToPack(card2.id, testPackId);
|
||||
|
||||
// Создать статистику для обеих карточек
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
correctAnswers: 3,
|
||||
incorrectAnswers: 1,
|
||||
);
|
||||
|
||||
final packStats = await db.wordStatisticsDao.getPackStatistics(
|
||||
testUserId,
|
||||
testPackId,
|
||||
);
|
||||
|
||||
expect(packStats.length, equals(2));
|
||||
expect(
|
||||
packStats.map((s) => s.cardId).toSet(),
|
||||
containsAll([testCardId, card2.id]),
|
||||
);
|
||||
});
|
||||
|
||||
test('не возвращает статистику из других паков', () async {
|
||||
// Создать другой пак и карточку
|
||||
final pack2 = await db.packDao.createPack(
|
||||
CardPacksCompanion.insert(
|
||||
title: 'Test Pack 2',
|
||||
subtitle: 'Test Subtitle 2',
|
||||
size: 5,
|
||||
),
|
||||
);
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
await db.packDao.linkCardToPack(card2.id, pack2.id);
|
||||
|
||||
// Создать статистику для карточки из другого пака
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
|
||||
// Статистика по первому паку должна быть пустой
|
||||
final packStats = await db.wordStatisticsDao.getPackStatistics(
|
||||
testUserId,
|
||||
testPackId,
|
||||
);
|
||||
|
||||
expect(packStats, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('getUserStatistics', () {
|
||||
test('возвращает всю статистику пользователя', () async {
|
||||
// Создать несколько карточек
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
final card3 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test3',
|
||||
translation: 'тест3',
|
||||
image: 'test3.png',
|
||||
),
|
||||
);
|
||||
|
||||
// Создать статистику для всех карточек
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
correctAnswers: 3,
|
||||
incorrectAnswers: 1,
|
||||
);
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card3.id,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
|
||||
final userStats = await db.wordStatisticsDao.getUserStatistics(
|
||||
testUserId,
|
||||
);
|
||||
|
||||
expect(userStats.length, equals(3));
|
||||
expect(
|
||||
userStats.map((s) => s.cardId).toSet(),
|
||||
containsAll([testCardId, card2.id, card3.id]),
|
||||
);
|
||||
});
|
||||
|
||||
test('не возвращает статистику других пользователей', () async {
|
||||
// Создать другого пользователя
|
||||
final user2 = await db.userDao.createUser(
|
||||
UsersCompanion.insert(
|
||||
externalUserId: 'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: Value('Test User 2'),
|
||||
),
|
||||
);
|
||||
await db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(userId: user2.id),
|
||||
);
|
||||
|
||||
// Создать статистику для обоих пользователей
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
await db.wordStatisticsDao.create(
|
||||
userId: user2.id,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 1,
|
||||
incorrectAnswers: 0,
|
||||
);
|
||||
|
||||
final userStats = await db.wordStatisticsDao.getUserStatistics(
|
||||
testUserId,
|
||||
);
|
||||
|
||||
expect(userStats.length, equals(1));
|
||||
expect(userStats.first.userId, equals(testUserId));
|
||||
});
|
||||
});
|
||||
|
||||
group('SoftDeleteMixin', () {
|
||||
test('selectActive фильтрует удаленные записи', () async {
|
||||
final stats1 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
final stats2 = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
correctAnswers: 3,
|
||||
incorrectAnswers: 1,
|
||||
);
|
||||
|
||||
// Удалить одну запись
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats1.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
// selectActive должен вернуть только активную запись
|
||||
final activeStats = await db.wordStatisticsDao.selectActive().get();
|
||||
expect(activeStats.length, equals(1));
|
||||
expect(activeStats.first.id, equals(stats2.id));
|
||||
});
|
||||
|
||||
test('getActiveById не возвращает удаленные записи', () async {
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
// Удалить запись
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await db.wordStatisticsDao.getActiveById(stats.id);
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
40
mnemo_cards_backend/test/database/database_test.dart
Normal file
40
mnemo_cards_backend/test/database/database_test.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
|
||||
void main() {
|
||||
group('Database Schema Tests', () {
|
||||
test('AppDatabase can be instantiated', () {
|
||||
// This test verifies that the database class can be created
|
||||
// Actual connection will be tested with real PostgreSQL instance
|
||||
expect(() => AppDatabase.connect(
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'test_db',
|
||||
username: 'test_user',
|
||||
password: 'test_pass',
|
||||
), returnsNormally);
|
||||
});
|
||||
|
||||
test('All tables are registered', () {
|
||||
// Verify that all expected tables are in the database
|
||||
final db = AppDatabase.connect(
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'test_db',
|
||||
username: 'test_user',
|
||||
password: 'test_pass',
|
||||
);
|
||||
|
||||
// Check that DAOs are available
|
||||
expect(db.userDao, isNotNull);
|
||||
expect(db.packDao, isNotNull);
|
||||
expect(db.testDao, isNotNull);
|
||||
expect(db.paymentDao, isNotNull);
|
||||
expect(db.subscriptionDao, isNotNull);
|
||||
expect(db.taskDao, isNotNull);
|
||||
expect(db.promoCodeDao, isNotNull);
|
||||
expect(db.discountDao, isNotNull);
|
||||
expect(db.statisticsDao, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
319
mnemo_cards_backend/test/smoke/smoke_tests.dart
Normal file
319
mnemo_cards_backend/test/smoke/smoke_tests.dart
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import 'dart:io';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/users.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/packs.dart';
|
||||
import 'package:mnemo_cards_backend/statistics/word_statistics_manager.dart';
|
||||
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
|
||||
|
||||
/// Smoke тесты для проверки базовой функциональности после деплоя
|
||||
///
|
||||
/// Эти тесты проверяют:
|
||||
/// - Создание БД и таблиц
|
||||
/// - Основные CRUD операции
|
||||
/// - Интеграцию WordStatisticsManager с TestManager
|
||||
/// - Расчет статистики через StatisticsCalculator
|
||||
///
|
||||
/// Требования:
|
||||
/// - PostgreSQL должен быть запущен
|
||||
/// - Тестовая БД: mnemo_cards_test
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late WordStatisticsManager wordStatsManager;
|
||||
late StatisticsCalculator statisticsCalculator;
|
||||
late String testUserId;
|
||||
late String testCardId;
|
||||
late String testPackId;
|
||||
|
||||
setUpAll(() async {
|
||||
// Подключение к тестовой БД
|
||||
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
|
||||
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
|
||||
|
||||
db = AppDatabase.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Создать схему БД
|
||||
await db.migrator.createAll();
|
||||
|
||||
// Создать тестовые данные
|
||||
final user = await db.userDao.createUser(
|
||||
UsersCompanion.insert(
|
||||
externalUserId: 'smoke_test_user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: Value('Smoke Test User'),
|
||||
email: Value('smoke@test.com'),
|
||||
),
|
||||
);
|
||||
testUserId = user.id;
|
||||
|
||||
await db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(userId: testUserId),
|
||||
);
|
||||
|
||||
final pack = await db.packDao.createPack(
|
||||
CardPacksCompanion.insert(
|
||||
title: 'Smoke Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
size: 10,
|
||||
),
|
||||
);
|
||||
testPackId = pack.id;
|
||||
|
||||
final card = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
image: 'test.png',
|
||||
),
|
||||
);
|
||||
testCardId = card.id;
|
||||
|
||||
await db.packDao.linkCardToPack(testCardId, testPackId);
|
||||
await db.packDao.linkUserToPack(testUserId, testPackId);
|
||||
|
||||
wordStatsManager = WordStatisticsManager(db);
|
||||
statisticsCalculator = StatisticsCalculator(db);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
// Очистить тестовые данные
|
||||
await db.delete(db.wordStatistics).go();
|
||||
await db.delete(db.cardPackCards).go();
|
||||
await db.delete(db.userPacks).go();
|
||||
await db.delete(db.gameCards).go();
|
||||
await db.delete(db.cardPacks).go();
|
||||
await db.delete(db.userDatas).go();
|
||||
await db.delete(db.users).go();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('Smoke Tests', () {
|
||||
test('БД создается без ошибок', () {
|
||||
expect(db, isNotNull);
|
||||
expect(db.userDao, isNotNull);
|
||||
expect(db.packDao, isNotNull);
|
||||
expect(db.wordStatisticsDao, isNotNull);
|
||||
expect(db.statisticsDao, isNotNull);
|
||||
});
|
||||
|
||||
test('WordStatistics записываются после ответа', () async {
|
||||
// Имитация ответа на карточку (как в TestManager.submitTest)
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.correctAnswers, equals(1));
|
||||
expect(stats.incorrectAnswers, equals(0));
|
||||
expect(stats.mastery, equals(1.0));
|
||||
});
|
||||
|
||||
test('packProgress рассчитывается корректно', () async {
|
||||
// Записать несколько ответов
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
// Создать еще одну карточку в паке
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
await db.packDao.linkCardToPack(card2.id, testPackId);
|
||||
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
isCorrect: false,
|
||||
);
|
||||
|
||||
// Рассчитать packProgress
|
||||
final packProgress = await statisticsCalculator.calculatePackProgress(
|
||||
testUserId,
|
||||
testPackId,
|
||||
);
|
||||
|
||||
expect(packProgress.packId, equals(testPackId));
|
||||
expect(packProgress.totalCards, equals(10));
|
||||
expect(packProgress.learnedCards, equals(2)); // 2 карточки с ответами
|
||||
expect(packProgress.averageAccuracy, closeTo(0.5, 0.01)); // 1 правильный, 1 неправильный
|
||||
});
|
||||
|
||||
test('studyDates рассчитываются из StudySessions', () async {
|
||||
// Создать сессию изучения
|
||||
final now = DateTime.now();
|
||||
await db.statisticsDao.createSession(
|
||||
StudySessionsCompanion.insert(
|
||||
userId: testUserId,
|
||||
packId: testPackId,
|
||||
startTime: PgDateTime(now),
|
||||
endTime: Value(PgDateTime(now.add(const Duration(minutes: 10)))),
|
||||
durationMinutes: 10,
|
||||
),
|
||||
);
|
||||
|
||||
final studyDates = await statisticsCalculator.calculateStudyDates(testUserId);
|
||||
|
||||
expect(studyDates, isNotEmpty);
|
||||
// Проверить что дата сегодняшнего дня присутствует
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
expect(
|
||||
studyDates.any((d) =>
|
||||
d.year == today.year &&
|
||||
d.month == today.month &&
|
||||
d.day == today.day),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('categoryMinutes рассчитываются из StudySessions', () async {
|
||||
// Создать несколько сессий
|
||||
final now = DateTime.now();
|
||||
await db.statisticsDao.createSession(
|
||||
StudySessionsCompanion.insert(
|
||||
userId: testUserId,
|
||||
packId: testPackId,
|
||||
startTime: PgDateTime(now),
|
||||
endTime: Value(PgDateTime(now.add(const Duration(minutes: 15)))),
|
||||
durationMinutes: 15,
|
||||
),
|
||||
);
|
||||
|
||||
await db.statisticsDao.createSession(
|
||||
StudySessionsCompanion.insert(
|
||||
userId: testUserId,
|
||||
packId: testPackId,
|
||||
startTime: PgDateTime(now.add(const Duration(hours: 1))),
|
||||
endTime: Value(PgDateTime(now.add(const Duration(hours: 1, minutes: 20)))),
|
||||
durationMinutes: 20,
|
||||
),
|
||||
);
|
||||
|
||||
final categoryMinutes = await statisticsCalculator.calculateCategoryMinutes(
|
||||
testUserId,
|
||||
);
|
||||
|
||||
expect(categoryMinutes, isNotEmpty);
|
||||
// Должно быть минимум 35 минут (15 + 20)
|
||||
final totalMinutes = categoryMinutes.values.fold<int>(0, (sum, minutes) => sum + minutes);
|
||||
expect(totalMinutes, greaterThanOrEqualTo(35));
|
||||
});
|
||||
|
||||
test('soft delete работает корректно', () async {
|
||||
// Создать запись статистики
|
||||
final stats = await db.wordStatisticsDao.create(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
correctAnswers: 5,
|
||||
incorrectAnswers: 2,
|
||||
);
|
||||
|
||||
// Soft delete
|
||||
await (db.update(db.wordStatistics)
|
||||
..where((w) => w.id.equals(stats.id)))
|
||||
.write(
|
||||
WordStatisticsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||
),
|
||||
);
|
||||
|
||||
// Проверить что запись не возвращается через getActiveById
|
||||
final activeRecord = await db.wordStatisticsDao.getActiveById(stats.id);
|
||||
expect(activeRecord, isNull);
|
||||
|
||||
// Проверить что запись физически существует в БД
|
||||
final allRecords = await db.select(db.wordStatistics).get();
|
||||
expect(allRecords.length, greaterThan(0));
|
||||
expect(allRecords.any((r) => r.id == stats.id && r.isDeleted), isTrue);
|
||||
});
|
||||
|
||||
test('WordStatisticsManager обновляет существующую статистику', () async {
|
||||
// Первый ответ
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
// Второй ответ
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
// Третий ответ (неправильный)
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: false,
|
||||
);
|
||||
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.correctAnswers, equals(2));
|
||||
expect(stats.incorrectAnswers, equals(1));
|
||||
expect(stats.mastery, closeTo(2.0 / 3.0, 0.001));
|
||||
});
|
||||
|
||||
test('getPackStatistics возвращает статистику по карточкам пака', () async {
|
||||
// Создать еще одну карточку в паке
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
await db.packDao.linkCardToPack(card2.id, testPackId);
|
||||
|
||||
// Записать ответы для обеих карточек
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
await wordStatsManager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
isCorrect: false,
|
||||
);
|
||||
|
||||
final packStats = await wordStatsManager.getPackStatistics(
|
||||
testUserId,
|
||||
testPackId,
|
||||
);
|
||||
|
||||
expect(packStats.length, equals(2));
|
||||
expect(
|
||||
packStats.map((s) => s.cardId).toSet(),
|
||||
containsAll([testCardId, card2.id]),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
import 'dart:io';
|
||||
import 'package:drift_postgres/drift_postgres.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/statistics/word_statistics_manager.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/users.dart';
|
||||
import 'package:mnemo_cards_backend/database/tables/packs.dart';
|
||||
|
||||
/// Unit тесты для WordStatisticsManager
|
||||
///
|
||||
/// Требования:
|
||||
/// - PostgreSQL должен быть запущен на localhost:5432
|
||||
/// - Тестовая БД: mnemo_cards_test
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late WordStatisticsManager manager;
|
||||
late String testUserId;
|
||||
late String testCardId;
|
||||
|
||||
setUpAll(() async {
|
||||
// Подключение к тестовой БД
|
||||
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
|
||||
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
|
||||
|
||||
db = AppDatabase.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
await db.migrator.createAll();
|
||||
|
||||
// Создать тестовые данные
|
||||
final user = await db.userDao.createUser(
|
||||
UsersCompanion.insert(
|
||||
externalUserId: 'test_user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: Value('Test User'),
|
||||
email: Value('test@example.com'),
|
||||
),
|
||||
);
|
||||
testUserId = user.id;
|
||||
|
||||
await db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(userId: testUserId),
|
||||
);
|
||||
|
||||
final card = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
image: 'test.png',
|
||||
),
|
||||
);
|
||||
testCardId = card.id;
|
||||
|
||||
manager = WordStatisticsManager(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.delete(db.wordStatistics).go();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await db.delete(db.wordStatistics).go();
|
||||
await db.delete(db.gameCards).go();
|
||||
await db.delete(db.userDatas).go();
|
||||
await db.delete(db.users).go();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('WordStatisticsManager', () {
|
||||
group('recordAnswer', () {
|
||||
test('создает новую запись при первом ответе', () async {
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.correctAnswers, equals(1));
|
||||
expect(stats.incorrectAnswers, equals(0));
|
||||
expect(stats.mastery, equals(1.0));
|
||||
expect(stats.lastReviewed, isNotNull);
|
||||
});
|
||||
|
||||
test('обновляет существующую запись при повторном ответе', () async {
|
||||
// Первый ответ - правильный
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
// Второй ответ - неправильный
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: false,
|
||||
);
|
||||
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.correctAnswers, equals(1));
|
||||
expect(stats.incorrectAnswers, equals(1));
|
||||
expect(stats.mastery, equals(0.5));
|
||||
});
|
||||
|
||||
test('увеличивает счетчики корректно', () async {
|
||||
// Несколько правильных ответов
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
// Один неправильный
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: false,
|
||||
);
|
||||
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(stats!.correctAnswers, equals(3));
|
||||
expect(stats.incorrectAnswers, equals(1));
|
||||
expect(stats.mastery, equals(0.75));
|
||||
});
|
||||
|
||||
test('обновляет lastReviewed при каждом ответе', () async {
|
||||
final before = DateTime.now();
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
final after = DateTime.now();
|
||||
|
||||
final stats = await db.wordStatisticsDao.getByUserAndCard(
|
||||
testUserId,
|
||||
testCardId,
|
||||
);
|
||||
|
||||
expect(stats!.lastReviewed, isNotNull);
|
||||
final lastReviewed = stats.lastReviewed!.toDateTime();
|
||||
expect(lastReviewed.isAfter(before), isTrue);
|
||||
expect(lastReviewed.isBefore(after), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('calculateMastery', () {
|
||||
test('возвращает 0.0 для нулевых значений', () {
|
||||
expect(manager.calculateMastery(0, 0), equals(0.0));
|
||||
});
|
||||
|
||||
test('возвращает 1.0 для 100% правильных ответов', () {
|
||||
expect(manager.calculateMastery(10, 0), equals(1.0));
|
||||
expect(manager.calculateMastery(5, 0), equals(1.0));
|
||||
});
|
||||
|
||||
test('возвращает 0.0 для 100% неправильных ответов', () {
|
||||
expect(manager.calculateMastery(0, 10), equals(0.0));
|
||||
expect(manager.calculateMastery(0, 5), equals(0.0));
|
||||
});
|
||||
|
||||
test('рассчитывает процент корректно', () {
|
||||
expect(manager.calculateMastery(7, 3), equals(0.7));
|
||||
expect(manager.calculateMastery(1, 1), equals(0.5));
|
||||
expect(manager.calculateMastery(3, 7), equals(0.3));
|
||||
});
|
||||
|
||||
test('работает с большими числами', () {
|
||||
expect(manager.calculateMastery(100, 0), equals(1.0));
|
||||
expect(manager.calculateMastery(75, 25), equals(0.75));
|
||||
expect(manager.calculateMastery(50, 50), equals(0.5));
|
||||
});
|
||||
});
|
||||
|
||||
group('getPackStatistics', () {
|
||||
test('возвращает статистику по карточкам пака', () async {
|
||||
// Создать пак и связать карточку
|
||||
final pack = await db.packDao.createPack(
|
||||
CardPacksCompanion.insert(
|
||||
title: 'Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
size: 10,
|
||||
),
|
||||
);
|
||||
await db.packDao.linkCardToPack(testCardId, pack.id);
|
||||
|
||||
// Создать еще одну карточку в паке
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
await db.packDao.linkCardToPack(card2.id, pack.id);
|
||||
|
||||
// Записать ответы
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
isCorrect: true,
|
||||
);
|
||||
|
||||
final packStats = await manager.getPackStatistics(
|
||||
testUserId,
|
||||
pack.id,
|
||||
);
|
||||
|
||||
expect(packStats.length, equals(2));
|
||||
expect(
|
||||
packStats.map((s) => s.cardId).toSet(),
|
||||
containsAll([testCardId, card2.id]),
|
||||
);
|
||||
});
|
||||
|
||||
test('возвращает пустой список если нет статистики', () async {
|
||||
final pack = await db.packDao.createPack(
|
||||
CardPacksCompanion.insert(
|
||||
title: 'Empty Pack',
|
||||
subtitle: 'Empty Subtitle',
|
||||
size: 5,
|
||||
),
|
||||
);
|
||||
|
||||
final packStats = await manager.getPackStatistics(
|
||||
testUserId,
|
||||
pack.id,
|
||||
);
|
||||
|
||||
expect(packStats, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('getUserStatistics', () {
|
||||
test('возвращает всю статистику пользователя', () async {
|
||||
// Создать несколько карточек
|
||||
final card2 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test2',
|
||||
translation: 'тест2',
|
||||
image: 'test2.png',
|
||||
),
|
||||
);
|
||||
final card3 = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'test3',
|
||||
translation: 'тест3',
|
||||
image: 'test3.png',
|
||||
),
|
||||
);
|
||||
|
||||
// Записать ответы для всех карточек
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: testCardId,
|
||||
isCorrect: true,
|
||||
);
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: card2.id,
|
||||
isCorrect: true,
|
||||
);
|
||||
await manager.recordAnswer(
|
||||
userId: testUserId,
|
||||
cardId: card3.id,
|
||||
isCorrect: false,
|
||||
);
|
||||
|
||||
final userStats = await manager.getUserStatistics(testUserId);
|
||||
|
||||
expect(userStats.length, equals(3));
|
||||
expect(
|
||||
userStats.map((s) => s.cardId).toSet(),
|
||||
containsAll([testCardId, card2.id, card3.id]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import 'dart:developer';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
|
||||
import 'package:telegram_web_app/telegram_web_app.dart';
|
||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||
import 'package:yx_state_flutter/yx_state_flutter.dart';
|
||||
|
||||
|
|
@ -66,7 +65,9 @@ class _AppInitializerState extends State<_AppInitializer> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _tryTelegramWebAppAuth() async {
|
||||
/// Try Telegram Web App authentication
|
||||
/// Returns true if authentication was successful, false otherwise
|
||||
Future<bool> _tryTelegramWebAppAuth() async {
|
||||
try {
|
||||
log('Checking Telegram Web App authentication...', name: 'App');
|
||||
|
||||
|
|
@ -89,14 +90,16 @@ class _AppInitializerState extends State<_AppInitializer> {
|
|||
widget.appScope.userScopeHolder.notifyAuthChanged();
|
||||
}
|
||||
|
||||
// Don't continue with normal auto-login if Telegram auth succeeded
|
||||
return;
|
||||
// Return true to indicate successful authentication
|
||||
return true;
|
||||
} else {
|
||||
log('Telegram Web App authentication not available or failed', name: 'App');
|
||||
return false;
|
||||
}
|
||||
} catch (e, s) {
|
||||
log('Error during Telegram Web App authentication', error: e, stackTrace: s);
|
||||
// Continue with normal flow - this is not a critical error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,40 +108,47 @@ class _AppInitializerState extends State<_AppInitializer> {
|
|||
log('Starting app initialization...', name: 'App');
|
||||
|
||||
// Try Telegram Web App authentication first
|
||||
await _tryTelegramWebAppAuth();
|
||||
final telegramAuthSuccess = await _tryTelegramWebAppAuth();
|
||||
|
||||
// Try auto-login first with timeout
|
||||
log('Attempting auto-login...', name: 'App');
|
||||
UserDto? user;
|
||||
try {
|
||||
user = await widget.appScope.authService.autoLogin().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () {
|
||||
log('Auto-login timed out, continuing as guest', name: 'App');
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
log('Auto-login failed with error, continuing as guest', error: e, name: 'App');
|
||||
user = null;
|
||||
}
|
||||
|
||||
if (widget.appScope.userScopeHolder.scope == null) {
|
||||
log('Creating UserScope...', name: 'App');
|
||||
await widget.appScope.userScopeHolder.create();
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
log('Auto-login successful, creating UserScope', name: 'App');
|
||||
// Create UserScope only for authenticated users
|
||||
await widget.appScope.userScopeHolder.scope!.userStateManager.setUser(user);
|
||||
// Notify router about auth change
|
||||
widget.appScope.userScopeHolder.notifyAuthChanged();
|
||||
log('UserScope created and user set', name: 'App');
|
||||
// If Telegram Web App auth succeeded, skip normal auto-login
|
||||
// (tokens are already saved and user is set)
|
||||
if (telegramAuthSuccess) {
|
||||
log('Telegram Web App auth succeeded, skipping normal auto-login', name: 'App');
|
||||
} else {
|
||||
log('No saved session, starting as guest without UserScope', name: 'App');
|
||||
// Try auto-login with timeout
|
||||
log('Attempting auto-login...', name: 'App');
|
||||
UserDto? user;
|
||||
try {
|
||||
user = await widget.appScope.authService.autoLogin().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () {
|
||||
log('Auto-login timed out, continuing as guest', name: 'App');
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
log('Auto-login failed with error, continuing as guest', error: e, name: 'App');
|
||||
user = null;
|
||||
}
|
||||
|
||||
// Create UserScope if needed
|
||||
if (widget.appScope.userScopeHolder.scope == null) {
|
||||
log('Creating UserScope...', name: 'App');
|
||||
await widget.appScope.userScopeHolder.create();
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
log('Auto-login successful, setting user', name: 'App');
|
||||
await widget.appScope.userScopeHolder.scope!.userStateManager.setUser(user);
|
||||
// Notify router about auth change
|
||||
widget.appScope.userScopeHolder.notifyAuthChanged();
|
||||
log('UserScope created and user set', name: 'App');
|
||||
} else {
|
||||
log('No saved session, starting as guest', name: 'App');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
log('Setting _isInitialized = true', name: 'App');
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
|
|
|
|||
|
|
@ -139,6 +139,11 @@ class ApiConfigV2 {
|
|||
static String packCardImage(String packId, String cardId) =>
|
||||
'/packs/$packId/cards/$cardId/image';
|
||||
|
||||
/// GET /api/v2/packs/{packId}/card/{cardId}/imageBack
|
||||
/// Get card back image
|
||||
static String packCardImageBack(String packId, String cardId) =>
|
||||
'/packs/$packId/cards/$cardId/imageBack';
|
||||
|
||||
/// GET /api/v2/voice/{voiceId}
|
||||
/// Get voice file
|
||||
static String voiceFile(String voiceId) => '/voice/$voiceId';
|
||||
|
|
@ -229,6 +234,12 @@ class ApiConfigV2 {
|
|||
return '$baseUrl${packCardImage(packId, cardId)}';
|
||||
}
|
||||
|
||||
/// Get card back image URL for a specific card
|
||||
/// Returns: http://baseUrl/api/v2/packs/{packId}/cards/{cardId}/imageBack
|
||||
static String getCardImageBackUrl(String packId, String cardId) {
|
||||
return '$baseUrl${packCardImageBack(packId, cardId)}';
|
||||
}
|
||||
|
||||
/// Get voice file URL by id
|
||||
/// Returns: http://baseUrl/api/v2/voice/{voiceId}
|
||||
static String getVoiceFileUrl(String voiceId) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class AppColors {
|
|||
static const Color backgroundBlue = Color(0xFFf0f0f0);
|
||||
static const Color testBlue = Color(0xffD0EAFF);
|
||||
static const Color borderGray = Color(0xffABABAB);
|
||||
static const Color mnemoRed = Color(0xFFE53935);
|
||||
|
||||
/// Цветовая палитра для черного (для MaterialColor)
|
||||
static const Map<int, Color> blackSwatch = {
|
||||
|
|
|
|||
|
|
@ -411,32 +411,134 @@ class _CardSide extends StatelessWidget {
|
|||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
child: Column(
|
||||
child: isFront
|
||||
? _buildFrontLayout()
|
||||
: _buildBackLayout(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFrontLayout() {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Изображение
|
||||
// Верхняя секция: Original + Translation + Voice Controls
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Voice Controls
|
||||
CardVoiceControls(
|
||||
packId: packId,
|
||||
cardId: card.id,
|
||||
accentColor: packColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Original текст - нормальный цвет для хорошей читаемости
|
||||
if (card.original != null && card.original!.isNotEmpty)
|
||||
MnemoText(
|
||||
card.original,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
),
|
||||
|
||||
// Translation - серый и меньше
|
||||
if (card.translation != null && card.translation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
MnemoText(
|
||||
card.translation,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Картинка в середине
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _buildImage(),
|
||||
),
|
||||
|
||||
// Текст
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Container(
|
||||
// Mnemo фраза внизу - красным цветом
|
||||
if (card.mnemo != null && card.mnemo!.isNotEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
color: packColor.withOpacity(0.05),
|
||||
child: isFront
|
||||
? _buildFrontText()
|
||||
: _buildBackText(),
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: MnemoText(
|
||||
card.mnemo,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.mnemoRed,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackLayout() {
|
||||
final hasImageBack = card.imageBack != null && card.imageBack!.isNotEmpty;
|
||||
|
||||
if (hasImageBack) {
|
||||
// Если есть imageBack - показываем его в центре и текст под ним
|
||||
return Column(
|
||||
children: [
|
||||
// Пустое пространство сверху (для выравнивания по высоте с front)
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Изображение на задней стороне
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _buildImageBack(),
|
||||
),
|
||||
|
||||
// Текст back под изображением
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _buildBackText(),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Если нет imageBack - показываем текст по центру
|
||||
return Center(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _buildBackText(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildImage() {
|
||||
if (card.image == null || card.image!.isEmpty) {
|
||||
return Container(
|
||||
|
|
@ -485,89 +587,91 @@ class _CardSide extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildFrontText() {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CardVoiceControls(
|
||||
packId: packId,
|
||||
cardId: card.id,
|
||||
accentColor: packColor,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (card.original != null && card.original!.isNotEmpty)
|
||||
MnemoText(
|
||||
card.original,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: packColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
),
|
||||
|
||||
if (card.translation != null && card.translation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return MnemoText(
|
||||
card.translation,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
Widget _buildImageBack() {
|
||||
if (card.imageBack == null || card.imageBack!.isEmpty) {
|
||||
return Container(
|
||||
color: packColor.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
size: 100,
|
||||
color: packColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final imageUrl = ApiConfigV2.getCardImageBackUrl(packId, card.id);
|
||||
|
||||
return Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) {
|
||||
return child;
|
||||
}
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
color: packColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: packColor.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.broken_image,
|
||||
size: 100,
|
||||
color: packColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildBackText() {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (card.mnemo != null && card.mnemo!.isNotEmpty)
|
||||
MnemoText(
|
||||
card.mnemo,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: packColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 5,
|
||||
)
|
||||
else
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Text(
|
||||
'Нет мнемоники',
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (card.back != null && card.back!.isNotEmpty)
|
||||
MnemoText(
|
||||
card.back,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 5,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Нет текста на обратной стороне',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue