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 UsersPage from '@/pages/UsersPage'
|
||||||
import TestsPage from '@/pages/TestsPage'
|
import TestsPage from '@/pages/TestsPage'
|
||||||
import Layout from '@/components/layout/Layout'
|
import Layout from '@/components/layout/Layout'
|
||||||
|
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<TokenRefreshProvider>
|
||||||
<Routes>
|
<div className="min-h-screen bg-background">
|
||||||
<Route
|
<Routes>
|
||||||
path="/login"
|
<Route
|
||||||
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
path="/login"
|
||||||
/>
|
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||||
<Route
|
/>
|
||||||
path="/*"
|
<Route
|
||||||
element={
|
path="/*"
|
||||||
isAuthenticated ? (
|
element={
|
||||||
<Layout>
|
isAuthenticated ? (
|
||||||
<Routes>
|
<Layout>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Routes>
|
||||||
<Route path="/cards" element={<CardsPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/packs" element={<PacksPage />} />
|
<Route path="/cards" element={<CardsPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/packs" element={<PacksPage />} />
|
||||||
<Route path="/tests" element={<TestsPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
</Routes>
|
<Route path="/tests" element={<TestsPage />} />
|
||||||
</Layout>
|
</Routes>
|
||||||
) : (
|
</Layout>
|
||||||
<Navigate to="/login" replace />
|
) : (
|
||||||
)
|
<Navigate to="/login" replace />
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
</Routes>
|
/>
|
||||||
</div>
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</TokenRefreshProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { adminApiClient } from './client'
|
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'
|
import type { AxiosError } from 'axios'
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
|
@ -46,4 +46,12 @@ export const authApi = {
|
||||||
const response = await adminApiClient.get('/api/v2/admin/auth/me')
|
const response = await adminApiClient.get('/api/v2/admin/auth/me')
|
||||||
return response.data
|
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 axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
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'
|
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
|
// Flag to prevent multiple logout calls
|
||||||
let isLoggingOut = false
|
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
|
// Response interceptor for error handling
|
||||||
const handleAuthError = (error: unknown) => {
|
const handleAuthError = async (error: unknown) => {
|
||||||
const axiosError = error as AxiosError
|
const axiosError = error as AxiosError
|
||||||
|
const originalRequest = axiosError.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
if (axiosError.response?.status === 401) {
|
if (axiosError.response?.status === 401) {
|
||||||
// Check if we're already on login page
|
// Check if we're already on login page
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const isPublicAuthEndpoint =
|
const isPublicAuthEndpoint =
|
||||||
axiosError.config?.url?.includes('/admin/auth/request-code') ||
|
originalRequest?.url?.includes('/admin/auth/request-code') ||
|
||||||
axiosError.config?.url?.includes('/admin/auth/verify-code') ||
|
originalRequest?.url?.includes('/admin/auth/verify-code') ||
|
||||||
axiosError.config?.url?.includes('/admin/auth/code-status')
|
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
|
// Don't logout for public auth endpoints or if already on login page
|
||||||
if (currentPath === '/login' || isPublicAuthEndpoint) {
|
if (currentPath === '/login' || isPublicAuthEndpoint) {
|
||||||
return Promise.reject(error)
|
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
|
// Prevent multiple logout calls
|
||||||
if (isLoggingOut) {
|
if (isLoggingOut) {
|
||||||
|
|
@ -86,6 +187,7 @@ const handleAuthError = (error: unknown) => {
|
||||||
// Remove token and update auth store
|
// Remove token and update auth store
|
||||||
// React Router will automatically redirect to /login when isAuthenticated becomes false
|
// React Router will automatically redirect to /login when isAuthenticated becomes false
|
||||||
localStorage.removeItem('admin_token')
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_refresh_token')
|
||||||
useAuthStore.getState().logout()
|
useAuthStore.getState().logout()
|
||||||
|
|
||||||
// Reset flag after a short delay to allow React Router to handle redirect
|
// 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
|
// Apply interceptors to both clients
|
||||||
|
apiClient.interceptors.request.use(checkAndRefreshToken)
|
||||||
apiClient.interceptors.request.use(addAuthToken)
|
apiClient.interceptors.request.use(addAuthToken)
|
||||||
apiClient.interceptors.response.use((response) => response, handleAuthError)
|
apiClient.interceptors.response.use((response) => response, handleAuthError)
|
||||||
|
|
||||||
|
adminApiClient.interceptors.request.use(checkAndRefreshToken)
|
||||||
adminApiClient.interceptors.request.use(addAuthToken)
|
adminApiClient.interceptors.request.use(addAuthToken)
|
||||||
adminApiClient.interceptors.response.use((response) => response, handleAuthError)
|
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('Login successful, setting token and user')
|
||||||
console.log('Token received:', data.token ? `Token exists, length: ${data.token.length}` : 'NO TOKEN!')
|
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)
|
console.log('User received:', data.user)
|
||||||
|
|
||||||
// Save token first
|
// Save token with refresh token and expiration
|
||||||
login(data.token, data.user)
|
login(data.token, data.refreshToken ?? null, data.expiresIn ?? null, data.user)
|
||||||
|
|
||||||
// Verify token was saved
|
// Verify token was saved
|
||||||
const savedToken = localStorage.getItem('admin_token')
|
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('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) {
|
if (!savedToken) {
|
||||||
console.error('CRITICAL: Token was not saved to localStorage!')
|
console.error('CRITICAL: Token was not saved to localStorage!')
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { toast } from 'sonner'
|
||||||
import { testsApi, isTestsApiError } from '@/api/tests'
|
import { testsApi, isTestsApiError } from '@/api/tests'
|
||||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||||
import type { TestDto, PaginatedResponse } from '@/types/models'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
@ -36,6 +38,8 @@ import {
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { ImageUpload } from '@/components/ui/image-upload'
|
import { ImageUpload } from '@/components/ui/image-upload'
|
||||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
|
||||||
|
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
|
||||||
|
|
||||||
export default function TestsPage() {
|
export default function TestsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -45,6 +49,11 @@ export default function TestsPage() {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
const [testToDelete, setTestToDelete] = useState<TestDto | null>(null)
|
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
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
|
@ -54,7 +63,7 @@ export default function TestsPage() {
|
||||||
version: '',
|
version: '',
|
||||||
time: '',
|
time: '',
|
||||||
timeSubtitle: '',
|
timeSubtitle: '',
|
||||||
questions: [] as TestDto['questions'],
|
questions: [] as Question[],
|
||||||
})
|
})
|
||||||
|
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
@ -141,6 +150,17 @@ export default function TestsPage() {
|
||||||
// Load full test data if we only have preview
|
// Load full test data if we only have preview
|
||||||
const fullTest = test.id ? await testsApi.getTest(test.id) : test
|
const fullTest = test.id ? await testsApi.getTest(test.id) : test
|
||||||
setSelectedTest(fullTest)
|
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({
|
setFormData({
|
||||||
name: fullTest.name || '',
|
name: fullTest.name || '',
|
||||||
color: fullTest.color || '',
|
color: fullTest.color || '',
|
||||||
|
|
@ -148,7 +168,7 @@ export default function TestsPage() {
|
||||||
version: fullTest.version || '',
|
version: fullTest.version || '',
|
||||||
time: fullTest.time || '',
|
time: fullTest.time || '',
|
||||||
timeSubtitle: fullTest.timeSubtitle || '',
|
timeSubtitle: fullTest.timeSubtitle || '',
|
||||||
questions: fullTest.questions || [],
|
questions,
|
||||||
})
|
})
|
||||||
setIsDialogOpen(true)
|
setIsDialogOpen(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -173,6 +193,9 @@ export default function TestsPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Преобразуем вопросы в JSON формат для отправки на бэкенд
|
||||||
|
const questionsJson = formData.questions.map((q) => questionToJson(q))
|
||||||
|
|
||||||
const testData: TestDto = {
|
const testData: TestDto = {
|
||||||
id: selectedTest?.id,
|
id: selectedTest?.id,
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
|
|
@ -181,7 +204,7 @@ export default function TestsPage() {
|
||||||
version: formData.version.trim() || undefined,
|
version: formData.version.trim() || undefined,
|
||||||
time: formData.time.trim() || undefined,
|
time: formData.time.trim() || undefined,
|
||||||
timeSubtitle: formData.timeSubtitle.trim() || undefined,
|
timeSubtitle: formData.timeSubtitle.trim() || undefined,
|
||||||
questions: formData.questions,
|
questions: questionsJson as any,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTest) {
|
if (selectedTest) {
|
||||||
|
|
@ -300,7 +323,11 @@ export default function TestsPage() {
|
||||||
<TableRow key={test.id || Math.random()}>
|
<TableRow key={test.id || Math.random()}>
|
||||||
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
|
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
|
||||||
<TableCell className="font-medium">{test.name}</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.version || 'N/A'}</TableCell>
|
||||||
<TableCell>{test.time || 'N/A'}</TableCell>
|
<TableCell>{test.time || 'N/A'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -365,7 +392,7 @@ export default function TestsPage() {
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{selectedTest ? 'Edit Test' : 'Create New Test'}
|
{selectedTest ? 'Edit Test' : 'Create New Test'}
|
||||||
|
|
@ -439,17 +466,29 @@ export default function TestsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Questions</Label>
|
<TestQuestionsManager
|
||||||
<div className="border rounded-lg p-4 bg-muted/50">
|
questions={formData.questions}
|
||||||
<p className="text-sm text-muted-foreground">
|
onChange={(questions) =>
|
||||||
{formData.questions.length} question(s) in this test
|
setFormData((prev) => ({ ...prev, questions }))
|
||||||
</p>
|
}
|
||||||
{formData.questions.length === 0 && (
|
onEdit={(question, index) => {
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
setEditingQuestion({ question, index })
|
||||||
Questions are managed separately. After creating the test, you can add questions through the backend API.
|
setIsQuestionDialogOpen(true)
|
||||||
</p>
|
}}
|
||||||
)}
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
</div>
|
/>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
@ -485,6 +524,31 @@ export default function TestsPage() {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,51 +5,90 @@ import type { UserDto } from '@/types/models'
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: UserDto | null
|
user: UserDto | null
|
||||||
token: string | null
|
token: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
expiresIn: number | null
|
||||||
|
tokenExpiresAt: number | null // timestamp when token expires
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthActions {
|
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
|
logout: () => void
|
||||||
setLoading: (loading: boolean) => void
|
setLoading: (loading: boolean) => void
|
||||||
setError: (error: string | null) => void
|
setError: (error: string | null) => void
|
||||||
|
shouldRefreshToken: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthStore = AuthState & AuthActions
|
type AuthStore = AuthState & AuthActions
|
||||||
|
|
||||||
export const useAuthStore = create<AuthStore>()(
|
export const useAuthStore = create<AuthStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
// State
|
// State
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresIn: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
// Actions
|
// 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)
|
console.log('[AuthStore] Saving token to localStorage, length:', token.length)
|
||||||
localStorage.setItem('admin_token', token)
|
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
|
// Verify it was saved
|
||||||
const savedToken = localStorage.getItem('admin_token')
|
const savedToken = localStorage.getItem('admin_token')
|
||||||
console.log('[AuthStore] Token saved, verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT found!')
|
console.log('[AuthStore] Token saved, verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT found!')
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
|
refreshToken: refreshToken ?? null,
|
||||||
|
expiresIn: expiresIn ?? null,
|
||||||
|
tokenExpiresAt,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
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: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('admin_token')
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_refresh_token')
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresIn: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -63,14 +102,45 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
setError: (error: string | null) => {
|
setError: (error: string | null) => {
|
||||||
set({ error, isLoading: false })
|
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',
|
name: 'admin-auth',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
user: state.user,
|
user: state.user,
|
||||||
token: state.token,
|
token: state.token,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
expiresIn: state.expiresIn,
|
||||||
|
tokenExpiresAt: state.tokenExpiresAt,
|
||||||
isAuthenticated: state.isAuthenticated,
|
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 {
|
export interface AuthResponse {
|
||||||
success?: boolean
|
success?: boolean
|
||||||
token: string
|
token: string
|
||||||
|
refreshToken?: string
|
||||||
|
expiresIn?: number
|
||||||
user: UserDto
|
user: UserDto
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenResponse {
|
||||||
|
success: boolean
|
||||||
|
token: string
|
||||||
|
refreshToken: string
|
||||||
|
expiresIn: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CodeRequest {
|
export interface CodeRequest {
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
@ -159,11 +169,9 @@ export interface CodeStatusResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test types
|
// Test types
|
||||||
export interface TestQuestion {
|
import type { Question } from './questions'
|
||||||
id?: string
|
|
||||||
questionType: string
|
export type TestQuestion = Question
|
||||||
body: unknown // JSON structure varies by question type
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestDto {
|
export interface TestDto {
|
||||||
id?: string
|
id?: string
|
||||||
|
|
@ -173,6 +181,6 @@ export interface TestDto {
|
||||||
version?: string
|
version?: string
|
||||||
time?: string
|
time?: string
|
||||||
timeSubtitle?: string
|
timeSubtitle?: string
|
||||||
questions: TestQuestion[]
|
questions: Question[]
|
||||||
statistics?: unknown
|
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({
|
return _json({
|
||||||
'success': true,
|
'success': true,
|
||||||
'token': tokens.accessToken,
|
'token': tokens.accessToken,
|
||||||
|
'refreshToken': tokens.refreshToken,
|
||||||
|
'expiresIn': tokens.expiresIn,
|
||||||
'user': {
|
'user': {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'name': user.name,
|
'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
|
/// Get list of admin Telegram IDs from environment variable or file
|
||||||
Future<List<String>> _getAdminIds() async {
|
Future<List<String>> _getAdminIds() async {
|
||||||
return AdminIdsService.getAdminIds();
|
return AdminIdsService.getAdminIds();
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@ Router _$AdminAuthApiV2Router(AdminAuthApiV2 service) {
|
||||||
router.add('POST', r'/admin/auth/verify-code', service.verifyCode);
|
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/code-status/<code>', service.getCodeStatus);
|
||||||
router.add('GET', r'/admin/auth/me', service.getCurrentUser);
|
router.add('GET', r'/admin/auth/me', service.getCurrentUser);
|
||||||
|
router.add('POST', r'/admin/auth/refresh', service.refreshToken);
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,11 @@ class AdminCardsApiV2 {
|
||||||
@Route.delete('/admin/cards/<cardId>')
|
@Route.delete('/admin/cards/<cardId>')
|
||||||
Future<Response> deleteCard(Request request, String cardId) async {
|
Future<Response> deleteCard(Request request, String cardId) async {
|
||||||
try {
|
try {
|
||||||
|
final auth = await _ensureAdmin(request);
|
||||||
|
if (auth.statusCode != 200) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
if (cardId.isEmpty) {
|
if (cardId.isEmpty) {
|
||||||
return Response.badRequest(
|
return Response.badRequest(
|
||||||
body: json.encode({
|
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
|
// Get questions
|
||||||
final questions = await _db.testDao.getTestQuestions(testId);
|
final questions = await _db.testDao.getTestQuestions(testId);
|
||||||
final questionsList = questions.map((q) {
|
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,
|
'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();
|
}).toList();
|
||||||
|
|
||||||
return _json({
|
return _json({
|
||||||
|
|
@ -184,7 +272,7 @@ class AdminTestsApiV2 {
|
||||||
'version': test.version ?? '1.0',
|
'version': test.version ?? '1.0',
|
||||||
'time': test.time,
|
'time': test.time,
|
||||||
'timeSubtitle': test.timeSubtitle,
|
'timeSubtitle': test.timeSubtitle,
|
||||||
'questions': questionsList.map((q) => q.toJson()).toList(),
|
'questions': questionsWithUrls,
|
||||||
});
|
});
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
print('Error in getTest: $e\n$s');
|
print('Error in getTest: $e\n$s');
|
||||||
|
|
@ -281,16 +369,32 @@ class AdminTestsApiV2 {
|
||||||
await _db.testDao.softDeleteTestQuestion(q.id);
|
await _db.testDao.softDeleteTestQuestion(q.id);
|
||||||
}
|
}
|
||||||
// Add new questions
|
// Add new questions
|
||||||
|
int orderIndex = 0;
|
||||||
for (final q in questions) {
|
for (final q in questions) {
|
||||||
final questionJson = q as Map<String, dynamic>;
|
final questionJson = q as Map<String, dynamic>;
|
||||||
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
|
final questionType = questionJson['questionType'] as String? ?? 'simple';
|
||||||
final questionBody = Map<String, dynamic>.from(questionJson);
|
|
||||||
questionBody.remove('questionType');
|
// Извлекаем ключевые поля
|
||||||
|
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(
|
await _db.testDao.createTestQuestion(
|
||||||
TestQuestionsCompanion.insert(
|
TestQuestionsCompanion.insert(
|
||||||
testId: testId,
|
testId: testId,
|
||||||
|
orderIndex: orderIndex++,
|
||||||
questionType: questionType,
|
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
|
// Add questions if provided
|
||||||
if (questions != null) {
|
if (questions != null) {
|
||||||
|
int orderIndex = 0;
|
||||||
for (final q in questions) {
|
for (final q in questions) {
|
||||||
final questionJson = q as Map<String, dynamic>;
|
final questionJson = q as Map<String, dynamic>;
|
||||||
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
|
final questionType = questionJson['questionType'] as String? ?? 'simple';
|
||||||
final questionBody = Map<String, dynamic>.from(questionJson);
|
|
||||||
questionBody.remove('questionType');
|
// Извлекаем ключевые поля
|
||||||
|
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(
|
await _db.testDao.createTestQuestion(
|
||||||
TestQuestionsCompanion.insert(
|
TestQuestionsCompanion.insert(
|
||||||
testId: newTest,
|
testId: newTest,
|
||||||
|
orderIndex: orderIndex++,
|
||||||
questionType: questionType,
|
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 /api/v2/packs/{packId}/cards/{cardId}/voices
|
||||||
/// Get card voices metadata
|
/// Get card voices metadata
|
||||||
/// Returns JSON list of VoiceDto
|
/// Returns JSON list of VoiceDto
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ Router _$PacksApiV2Router(PacksApiV2 service) {
|
||||||
r'/packs/<packId>/cards/<cardId>/image',
|
r'/packs/<packId>/cards/<cardId>/image',
|
||||||
service.getCardImage,
|
service.getCardImage,
|
||||||
);
|
);
|
||||||
|
router.add(
|
||||||
|
'GET',
|
||||||
|
r'/packs/<packId>/cards/<cardId>/imageBack',
|
||||||
|
service.getCardImageBack,
|
||||||
|
);
|
||||||
router.add(
|
router.add(
|
||||||
'GET',
|
'GET',
|
||||||
r'/packs/<packId>/cards/<cardId>/voices',
|
r'/packs/<packId>/cards/<cardId>/voices',
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,11 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
|
||||||
|
|
||||||
// ==================== GameCards ====================
|
// ==================== GameCards ====================
|
||||||
|
|
||||||
/// Получить карточку по ID
|
/// Получить карточку по ID (только активные)
|
||||||
Future<GameCard?> getCardById(String 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 'package:drift_postgres/drift_postgres.dart';
|
||||||
import '../database.dart';
|
import '../database.dart';
|
||||||
import '../tables/tests.dart';
|
import '../tables/tests.dart';
|
||||||
import '../tables/packs.dart';
|
|
||||||
|
|
||||||
part 'test_dao.g.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)))
|
return (update(tests)..where((t) => t.id.equals(testId)))
|
||||||
.write(TestsCompanion(
|
.write(TestsCompanion(
|
||||||
isDeleted: const Value(true),
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(PgDateTime(DateTime.now())),
|
||||||
updatedAt: Value(PgDateTime(DateTime.now())),
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -70,13 +70,23 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
||||||
mode: InsertMode.insertOrIgnore,
|
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 ====================
|
// ==================== TestQuestions ====================
|
||||||
|
|
||||||
/// Получить вопросы теста (только активные)
|
/// Получить вопросы теста (только активные, отсортированные по orderIndex)
|
||||||
Future<List<TestQuestion>> getTestQuestions(String testId) {
|
Future<List<TestQuestion>> getTestQuestions(String testId) {
|
||||||
return (select(testQuestions)
|
return (select(testQuestions)
|
||||||
..where((tq) => tq.testId.equals(testId) & tq.isDeleted.equals(false))
|
..where((tq) => tq.testId.equals(testId) & tq.isDeleted.equals(false))
|
||||||
|
..orderBy([(tq) => OrderingTerm(expression: tq.orderIndex)])
|
||||||
).get();
|
).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:postgres/postgres.dart' as pg;
|
import 'package:postgres/postgres.dart' as pg;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
// Импорт конвертеров (нужен для генерации кода)
|
// Импорт конвертеров (нужен для генерации кода)
|
||||||
import 'converters.dart';
|
import 'converters.dart';
|
||||||
|
|
@ -122,7 +123,7 @@ class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase(super.e);
|
AppDatabase(super.e);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
/// Factory для подключения к PostgreSQL
|
/// Factory для подключения к PostgreSQL
|
||||||
static AppDatabase connect({
|
static AppDatabase connect({
|
||||||
|
|
@ -177,10 +178,10 @@ class AppDatabase extends _$AppDatabase {
|
||||||
onUpgrade: (Migrator m, int from, int to) async {
|
onUpgrade: (Migrator m, int from, int to) async {
|
||||||
print('Migrating database from version $from to $to');
|
print('Migrating database from version $from to $to');
|
||||||
|
|
||||||
// Миграции при обновлении схемы
|
// Миграция с версии 1 на 2: обновление структуры тестов
|
||||||
// if (from < 2) {
|
if (from < 2) {
|
||||||
// await m.addColumn(users, users.phoneNumber);
|
await _migrateToV2(m);
|
||||||
// }
|
}
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
print('Opening database connection...');
|
print('Opening database connection...');
|
||||||
|
|
@ -251,4 +252,63 @@ class AppDatabase extends _$AppDatabase {
|
||||||
|
|
||||||
print('Indexes created successfully');
|
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()
|
TextColumn get testId => text()
|
||||||
.references(Tests, #id, onDelete: KeyAction.cascade)();
|
.references(Tests, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
// Порядок вопроса в тесте
|
||||||
|
IntColumn get orderIndex => integer()
|
||||||
|
.withDefault(const Constant(0))();
|
||||||
|
|
||||||
// Тип вопроса (enum as string)
|
// Тип вопроса (enum as string)
|
||||||
TextColumn get questionType => text()();
|
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
|
// Audit
|
||||||
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
|
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
|
||||||
|
|
@ -78,11 +95,34 @@ class TestStatistics extends Table {
|
||||||
TextColumn get testId => text()
|
TextColumn get testId => text()
|
||||||
.references(Tests, #id, onDelete: KeyAction.cascade)();
|
.references(Tests, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
// Результаты (JSON)
|
// Сводные метрики (вынесены для быстрого доступа и аналитики)
|
||||||
TextColumn get results => text()
|
IntColumn get totalQuestions => integer()
|
||||||
.withDefault(const Constant('{}'))
|
.withDefault(const Constant(0))();
|
||||||
.map(const JsonMapConverter())();
|
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)
|
Column<PgDateTime> get completedAt => customType(PgTypes.timestampWithTimezone)
|
||||||
.withDefault(now())();
|
.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', () {
|
group('PacksApiV2 - Get Pack Tests', () {
|
||||||
test('should return 401 for unauthenticated request', () async {
|
test('should return 401 for unauthenticated request', () async {
|
||||||
final request = buildRequest(
|
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:flutter/material.dart';
|
||||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.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_scope_flutter/yx_scope_flutter.dart';
|
||||||
import 'package:yx_state_flutter/yx_state_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 {
|
try {
|
||||||
log('Checking Telegram Web App authentication...', name: 'App');
|
log('Checking Telegram Web App authentication...', name: 'App');
|
||||||
|
|
||||||
|
|
@ -89,14 +90,16 @@ class _AppInitializerState extends State<_AppInitializer> {
|
||||||
widget.appScope.userScopeHolder.notifyAuthChanged();
|
widget.appScope.userScopeHolder.notifyAuthChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't continue with normal auto-login if Telegram auth succeeded
|
// Return true to indicate successful authentication
|
||||||
return;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
log('Telegram Web App authentication not available or failed', name: 'App');
|
log('Telegram Web App authentication not available or failed', name: 'App');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log('Error during Telegram Web App authentication', error: e, stackTrace: s);
|
log('Error during Telegram Web App authentication', error: e, stackTrace: s);
|
||||||
// Continue with normal flow - this is not a critical error
|
// 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');
|
log('Starting app initialization...', name: 'App');
|
||||||
|
|
||||||
// Try Telegram Web App authentication first
|
// Try Telegram Web App authentication first
|
||||||
await _tryTelegramWebAppAuth();
|
final telegramAuthSuccess = await _tryTelegramWebAppAuth();
|
||||||
|
|
||||||
// Try auto-login first with timeout
|
// If Telegram Web App auth succeeded, skip normal auto-login
|
||||||
log('Attempting auto-login...', name: 'App');
|
// (tokens are already saved and user is set)
|
||||||
UserDto? user;
|
if (telegramAuthSuccess) {
|
||||||
try {
|
log('Telegram Web App auth succeeded, skipping normal auto-login', name: 'App');
|
||||||
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');
|
|
||||||
} else {
|
} 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');
|
log('Setting _isInitialized = true', name: 'App');
|
||||||
setState(() {
|
setState(() {
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,11 @@ class ApiConfigV2 {
|
||||||
static String packCardImage(String packId, String cardId) =>
|
static String packCardImage(String packId, String cardId) =>
|
||||||
'/packs/$packId/cards/$cardId/image';
|
'/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 /api/v2/voice/{voiceId}
|
||||||
/// Get voice file
|
/// Get voice file
|
||||||
static String voiceFile(String voiceId) => '/voice/$voiceId';
|
static String voiceFile(String voiceId) => '/voice/$voiceId';
|
||||||
|
|
@ -229,6 +234,12 @@ class ApiConfigV2 {
|
||||||
return '$baseUrl${packCardImage(packId, cardId)}';
|
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
|
/// Get voice file URL by id
|
||||||
/// Returns: http://baseUrl/api/v2/voice/{voiceId}
|
/// Returns: http://baseUrl/api/v2/voice/{voiceId}
|
||||||
static String getVoiceFileUrl(String voiceId) {
|
static String getVoiceFileUrl(String voiceId) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class AppColors {
|
||||||
static const Color backgroundBlue = Color(0xFFf0f0f0);
|
static const Color backgroundBlue = Color(0xFFf0f0f0);
|
||||||
static const Color testBlue = Color(0xffD0EAFF);
|
static const Color testBlue = Color(0xffD0EAFF);
|
||||||
static const Color borderGray = Color(0xffABABAB);
|
static const Color borderGray = Color(0xffABABAB);
|
||||||
|
static const Color mnemoRed = Color(0xFFE53935);
|
||||||
|
|
||||||
/// Цветовая палитра для черного (для MaterialColor)
|
/// Цветовая палитра для черного (для MaterialColor)
|
||||||
static const Map<int, Color> blackSwatch = {
|
static const Map<int, Color> blackSwatch = {
|
||||||
|
|
|
||||||
|
|
@ -411,32 +411,134 @@ class _CardSide extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(21),
|
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: [
|
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(
|
Expanded(
|
||||||
flex: 3,
|
|
||||||
child: _buildImage(),
|
child: _buildImage(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Текст
|
// Mnemo фраза внизу - красным цветом
|
||||||
Expanded(
|
if (card.mnemo != null && card.mnemo!.isNotEmpty)
|
||||||
flex: 2,
|
Container(
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
color: packColor.withOpacity(0.05),
|
child: Center(
|
||||||
child: isFront
|
child: SingleChildScrollView(
|
||||||
? _buildFrontText()
|
child: MnemoText(
|
||||||
: _buildBackText(),
|
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() {
|
Widget _buildImage() {
|
||||||
if (card.image == null || card.image!.isEmpty) {
|
if (card.image == null || card.image!.isEmpty) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -485,89 +587,91 @@ class _CardSide extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFrontText() {
|
Widget _buildImageBack() {
|
||||||
return Center(
|
if (card.imageBack == null || card.imageBack!.isEmpty) {
|
||||||
child: SingleChildScrollView(
|
return Container(
|
||||||
child: Column(
|
color: packColor.withOpacity(0.1),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Center(
|
||||||
children: [
|
child: Icon(
|
||||||
CardVoiceControls(
|
Icons.collections_bookmark,
|
||||||
packId: packId,
|
size: 100,
|
||||||
cardId: card.id,
|
color: packColor.withOpacity(0.3),
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
Widget _buildBackText() {
|
||||||
return Center(
|
return Builder(
|
||||||
child: SingleChildScrollView(
|
builder: (context) {
|
||||||
child: Column(
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
return Center(
|
||||||
if (card.mnemo != null && card.mnemo!.isNotEmpty)
|
child: SingleChildScrollView(
|
||||||
MnemoText(
|
child: Column(
|
||||||
card.mnemo,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textStyle: TextStyle(
|
children: [
|
||||||
fontSize: 28,
|
if (card.back != null && card.back!.isNotEmpty)
|
||||||
fontWeight: FontWeight.w700,
|
MnemoText(
|
||||||
color: packColor,
|
card.back,
|
||||||
),
|
textStyle: TextStyle(
|
||||||
textAlign: TextAlign.center,
|
fontSize: 28,
|
||||||
maxLines: 5,
|
fontWeight: FontWeight.w700,
|
||||||
)
|
color: colorScheme.onSurface,
|
||||||
else
|
),
|
||||||
Builder(
|
textAlign: TextAlign.center,
|
||||||
builder: (context) {
|
maxLines: 5,
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
)
|
||||||
return Text(
|
else
|
||||||
'Нет мнемоники',
|
Text(
|
||||||
|
'Нет текста на обратной стороне',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: colorScheme.onSurface.withOpacity(0.38),
|
color: colorScheme.onSurface.withOpacity(0.38),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue