admin cleanuo
This commit is contained in:
parent
afe5879b62
commit
91b66ed81d
20 changed files with 8 additions and 3538 deletions
|
|
@ -2,10 +2,8 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
|||
import { useAuthStore } from '@/stores/authStore'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import DashboardPage from '@/pages/DashboardPage'
|
||||
import CardsPage from '@/pages/CardsPage'
|
||||
import PacksPage from '@/pages/PacksPage'
|
||||
import UsersPage from '@/pages/UsersPage'
|
||||
import TestsPage from '@/pages/TestsPage'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||
|
||||
|
|
@ -27,10 +25,8 @@ function App() {
|
|||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/cards" element={<CardsPage />} />
|
||||
<Route path="/packs" element={<PacksPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/tests" element={<TestsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface CardsApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
field?: string
|
||||
originalError?: unknown
|
||||
name: 'CardsApiError'
|
||||
}
|
||||
|
||||
export function createCardsApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
field?: string,
|
||||
originalError?: unknown
|
||||
): CardsApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
field,
|
||||
originalError,
|
||||
name: 'CardsApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isCardsApiError(error: unknown): error is CardsApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'CardsApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const cardsApi = {
|
||||
// Get all cards with pagination and search
|
||||
getCards: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}): Promise<PaginatedResponse<GameCardDto>> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/v2/admin/cards', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
search: params?.search,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Failed to load cards',
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Get a specific card by ID
|
||||
getCard: async (cardId: string): Promise<GameCardDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to load card ${cardId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Create or update a card
|
||||
upsertCard: async (card: GameCardDto): Promise<{ success: boolean; card: GameCardDto }> => {
|
||||
try {
|
||||
const response = await adminApiClient.post('/api/v2/admin/cards', card)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
||||
const isUpdate = card.id !== null && card.id !== undefined && card.id !== ''
|
||||
const operation = isUpdate ? 'update card' : 'create card'
|
||||
const message = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to ${operation}`
|
||||
|
||||
let fullMessage = message
|
||||
if (axiosError.response?.data?.details) {
|
||||
fullMessage += `. ${axiosError.response.data.details}`
|
||||
}
|
||||
if (axiosError.response?.data?.field) {
|
||||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||
}
|
||||
|
||||
throw createCardsApiError(
|
||||
fullMessage,
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a card by ID
|
||||
deleteCard: async (cardId: string): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/v2/admin/cards/${cardId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to delete card ${cardId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { GameCardDto } from '@/types/models'
|
||||
import { cardsApi } from './cards'
|
||||
import { voicesApi } from './voices'
|
||||
import { upsertCardWithOptionalVoice } from './cardsWithVoice'
|
||||
|
||||
describe('upsertCardWithOptionalVoice', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not add voice when none provided', async () => {
|
||||
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
|
||||
success: true,
|
||||
card: { id: 'card-1' } satisfies GameCardDto,
|
||||
})
|
||||
const addVoiceSpy = vi
|
||||
.spyOn(voicesApi, 'addCardVoice')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
voice: {
|
||||
id: 'voice-1',
|
||||
cardId: 'card-1',
|
||||
voiceUrl: 'BASE64',
|
||||
language: 'en',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
await upsertCardWithOptionalVoice({ id: null } satisfies GameCardDto)
|
||||
|
||||
expect(addVoiceSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds voice after upsert when provided (uses response card id)', async () => {
|
||||
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
|
||||
success: true,
|
||||
card: { id: 'card-42' } satisfies GameCardDto,
|
||||
})
|
||||
const addVoiceSpy = vi
|
||||
.spyOn(voicesApi, 'addCardVoice')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
voice: {
|
||||
id: 'voice-1',
|
||||
cardId: 'card-42',
|
||||
voiceUrl: 'BASE64',
|
||||
language: 'en',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
await upsertCardWithOptionalVoice(
|
||||
{ id: null } satisfies GameCardDto,
|
||||
{ voiceUrl: 'BASE64', language: 'en' },
|
||||
)
|
||||
|
||||
expect(addVoiceSpy).toHaveBeenCalledWith('card-42', 'BASE64', 'en')
|
||||
})
|
||||
|
||||
it('falls back to request card id when response card id is missing', async () => {
|
||||
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
|
||||
success: true,
|
||||
card: { id: null } satisfies GameCardDto,
|
||||
})
|
||||
const addVoiceSpy = vi
|
||||
.spyOn(voicesApi, 'addCardVoice')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
voice: {
|
||||
id: 'voice-1',
|
||||
cardId: 'card-req',
|
||||
voiceUrl: 'BASE64',
|
||||
language: 'es',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
await upsertCardWithOptionalVoice(
|
||||
{ id: 'card-req' } satisfies GameCardDto,
|
||||
{ voiceUrl: 'BASE64', language: 'es' },
|
||||
)
|
||||
|
||||
expect(addVoiceSpy).toHaveBeenCalledWith('card-req', 'BASE64', 'es')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { GameCardDto } from '@/types/models'
|
||||
import { cardsApi } from './cards'
|
||||
import { voicesApi } from './voices'
|
||||
|
||||
export interface PendingVoiceUpload {
|
||||
voiceUrl: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export async function upsertCardWithOptionalVoice(
|
||||
card: GameCardDto,
|
||||
voice?: PendingVoiceUpload,
|
||||
): Promise<{ success: boolean; card: GameCardDto }> {
|
||||
console.log('🔍 upsertCardWithOptionalVoice: Starting', {
|
||||
cardId: card.id,
|
||||
hasVoice: !!voice,
|
||||
voiceUrl: voice?.voiceUrl,
|
||||
language: voice?.language,
|
||||
})
|
||||
|
||||
const response = await cardsApi.upsertCard(card)
|
||||
|
||||
const responseCardId = response.card?.id != null ? String(response.card.id) : ''
|
||||
const requestCardId = card.id != null ? String(card.id) : ''
|
||||
const cardId = responseCardId || requestCardId
|
||||
|
||||
console.log('🔍 upsertCardWithOptionalVoice: After upsertCard', {
|
||||
responseCardId,
|
||||
requestCardId,
|
||||
cardId,
|
||||
hasVoice: !!voice,
|
||||
voiceUrl: voice?.voiceUrl,
|
||||
})
|
||||
|
||||
if (!voice || !voice.voiceUrl || !cardId) {
|
||||
console.log('⚠️ upsertCardWithOptionalVoice: Skipping voice link', {
|
||||
hasVoice: !!voice,
|
||||
voiceUrl: voice?.voiceUrl,
|
||||
cardId,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
console.log('✅ upsertCardWithOptionalVoice: Calling addCardVoice', {
|
||||
cardId,
|
||||
voiceUrl: voice.voiceUrl,
|
||||
language: voice.language,
|
||||
})
|
||||
|
||||
try {
|
||||
await voicesApi.addCardVoice(cardId, voice.voiceUrl, voice.language)
|
||||
console.log('✅ upsertCardWithOptionalVoice: Successfully added voice')
|
||||
} catch (error) {
|
||||
console.error('❌ upsertCardWithOptionalVoice: Error adding voice', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ describe('API Clients', () => {
|
|||
describe('apiClient', () => {
|
||||
it('should be defined and have correct base URL', () => {
|
||||
expect(apiClient).toBeDefined()
|
||||
expect(apiClient.defaults.baseURL).toBe('https://api.mnemo-cards.online')
|
||||
expect(apiClient.defaults.baseURL).toBe('https://api.party-games.online')
|
||||
})
|
||||
|
||||
it('should have interceptors configured', () => {
|
||||
|
|
@ -24,7 +24,7 @@ describe('API Clients', () => {
|
|||
describe('adminApiClient', () => {
|
||||
it('should always use production API URL', () => {
|
||||
expect(adminApiClient).toBeDefined()
|
||||
expect(adminApiClient.defaults.baseURL).toBe('https://api.mnemo-cards.online')
|
||||
expect(adminApiClient.defaults.baseURL).toBe('https://api.party-games.online')
|
||||
})
|
||||
|
||||
it('should be a separate instance from apiClient', () => {
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { TestDto, PaginatedResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface TestsApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
field?: string
|
||||
originalError?: unknown
|
||||
name: 'TestsApiError'
|
||||
}
|
||||
|
||||
export function createTestsApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
field?: string,
|
||||
originalError?: unknown
|
||||
): TestsApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
field,
|
||||
originalError,
|
||||
name: 'TestsApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isTestsApiError(error: unknown): error is TestsApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'TestsApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const testsApi = {
|
||||
// Get all tests with pagination and search
|
||||
getTests: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}): Promise<PaginatedResponse<TestDto>> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/v2/admin/tests', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
search: params?.search,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||
throw createTestsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Failed to load tests',
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Get a specific test by ID
|
||||
getTest: async (testId: string): Promise<TestDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/tests/${testId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createTestsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to load test ${testId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Create or update a test
|
||||
upsertTest: async (test: TestDto): Promise<{ success: boolean; test: TestDto }> => {
|
||||
try {
|
||||
const response = await adminApiClient.post('/api/v2/admin/tests', test)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
||||
const isUpdate = test.id && test.id.length > 0
|
||||
const operation = isUpdate ? 'update test' : 'create test'
|
||||
const message = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to ${operation}`
|
||||
|
||||
let fullMessage = message
|
||||
if (axiosError.response?.data?.details) {
|
||||
fullMessage += `. ${axiosError.response.data.details}`
|
||||
}
|
||||
if (axiosError.response?.data?.field) {
|
||||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||
}
|
||||
|
||||
throw createTestsApiError(
|
||||
fullMessage,
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a test by ID
|
||||
deleteTest: async (testId: string): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/v2/admin/tests/${testId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createTestsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to delete test ${testId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { adminApiClient } from './client'
|
||||
|
||||
export interface VoiceDto {
|
||||
id: string
|
||||
cardId: string
|
||||
voiceUrl: string
|
||||
language: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface VoiceResponse {
|
||||
items: VoiceDto[]
|
||||
}
|
||||
|
||||
export const voicesApi = {
|
||||
// Get all voices for a card
|
||||
getCardVoices: async (cardId: string): Promise<VoiceResponse> => {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}/voices`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Add a voice to a card
|
||||
addCardVoice: async (
|
||||
cardId: string,
|
||||
voiceUrl: string,
|
||||
language: string = 'en'
|
||||
): Promise<{ success: boolean; voice: VoiceDto }> => {
|
||||
const response = await adminApiClient.post(`/api/v2/admin/cards/${cardId}/voices`, {
|
||||
voiceUrl,
|
||||
language,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Remove a voice from a card
|
||||
removeCardVoice: async (
|
||||
cardId: string,
|
||||
voiceId: string
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const response = await adminApiClient.delete(
|
||||
`/api/v2/admin/cards/${cardId}/voices/${voiceId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { BulkCardEditor } from '@/components/BulkCardEditor'
|
||||
|
||||
vi.mock('@/api/packs', () => {
|
||||
return {
|
||||
packsApi: {
|
||||
getPacks: vi.fn(),
|
||||
},
|
||||
isPacksApiError: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
import { packsApi } from '@/api/packs'
|
||||
|
||||
function renderWithQuery(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BulkCardEditor', () => {
|
||||
it('loads packs and shows their titles in the pack select', async () => {
|
||||
vi.mocked(packsApi.getPacks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'pack-1',
|
||||
title: 'Pack One',
|
||||
cards: 10,
|
||||
enabled: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'pack-empty-title',
|
||||
title: ' ',
|
||||
cards: 0,
|
||||
enabled: true,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const images = [
|
||||
{
|
||||
id: 'img-1',
|
||||
file: new File(['x'], 'img-1.png', { type: 'image/png' }),
|
||||
preview: 'data:image/png;base64,AA==',
|
||||
objectId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
]
|
||||
|
||||
renderWithQuery(
|
||||
<BulkCardEditor
|
||||
images={images}
|
||||
onComplete={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(packsApi.getPacks).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
search: '',
|
||||
})
|
||||
})
|
||||
|
||||
const placeholder = screen.getByText('Select a pack (optional)')
|
||||
const trigger = placeholder.closest('button')
|
||||
expect(trigger).toBeTruthy()
|
||||
|
||||
trigger!.focus()
|
||||
fireEvent.keyDown(trigger!, { key: 'ArrowDown' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('None')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pack One')).toBeInTheDocument()
|
||||
expect(screen.getByText('pack-empty-title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,412 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { GameCardDto } from '@/types/models'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import { Label } from './ui/label'
|
||||
import { Textarea } from './ui/textarea'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
|
||||
import { Badge } from './ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
||||
import { ChevronLeft, ChevronRight, Save, X } from 'lucide-react'
|
||||
|
||||
interface UploadedImage {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
objectId: string // Object ID in MinIO (UUID)
|
||||
presignedUrl?: string // Presigned URL for preview
|
||||
}
|
||||
|
||||
interface CardData {
|
||||
imageId: string
|
||||
packId: string
|
||||
original: string
|
||||
translation: string
|
||||
mnemo: string
|
||||
transcription: string
|
||||
transcriptionMnemo: string
|
||||
back: string
|
||||
imageBack?: string
|
||||
isSaved: boolean
|
||||
cardId?: string
|
||||
}
|
||||
|
||||
interface BulkCardEditorProps {
|
||||
images: UploadedImage[]
|
||||
defaultPackId?: string
|
||||
onComplete: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BulkCardEditor({ images, defaultPackId, onComplete, onCancel }: BulkCardEditorProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [cardsData, setCardsData] = useState<Map<string, CardData>>(() => {
|
||||
const initialData = new Map<string, CardData>()
|
||||
if (images.length > 0) {
|
||||
images.forEach((image) => {
|
||||
initialData.set(image.id, {
|
||||
imageId: image.id,
|
||||
packId: defaultPackId || '',
|
||||
original: '',
|
||||
translation: '',
|
||||
mnemo: '',
|
||||
transcription: '',
|
||||
transcriptionMnemo: '',
|
||||
back: '',
|
||||
imageBack: undefined,
|
||||
isSaved: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
return initialData
|
||||
})
|
||||
const [packsData, setPacksData] = useState<{ id: string; title: string }[]>([])
|
||||
|
||||
// Load packs
|
||||
useEffect(() => {
|
||||
packsApi
|
||||
.getPacks({ page: 1, limit: 100, search: '' })
|
||||
.then((response) => {
|
||||
try {
|
||||
if (response && response.items && Array.isArray(response.items)) {
|
||||
const packs = response.items.map((pack) => {
|
||||
const id = String(pack.id)
|
||||
const title = pack.title?.trim() ? String(pack.title) : id
|
||||
return { id, title }
|
||||
})
|
||||
setPacksData(packs)
|
||||
} else {
|
||||
console.warn('Unexpected packs response format:', response)
|
||||
setPacksData([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing packs response:', error)
|
||||
setPacksData([])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
console.error('Failed to load packs:', error)
|
||||
toast.error(`Failed to load packs: ${errorMessage}`)
|
||||
// Initialize with empty array so component doesn't crash
|
||||
setPacksData([])
|
||||
})
|
||||
}, [])
|
||||
|
||||
const currentImage = images[currentIndex]
|
||||
const currentCard = currentImage ? cardsData.get(currentImage.id) : undefined
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
if (currentImage) {
|
||||
const imageId = currentImage.id
|
||||
setCardsData((prev) => {
|
||||
const newData = new Map(prev)
|
||||
const cardData = newData.get(imageId)
|
||||
if (cardData && response.card) {
|
||||
newData.set(imageId, {
|
||||
...cardData,
|
||||
isSaved: true,
|
||||
cardId: response.card.id || undefined,
|
||||
})
|
||||
}
|
||||
return newData
|
||||
})
|
||||
}
|
||||
toast.success('Card saved successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'save', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error saving card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const handleFieldChange = (field: keyof CardData, value: string) => {
|
||||
if (!currentCard || !currentImage) return
|
||||
|
||||
setCardsData((prev) => {
|
||||
const newData = new Map(prev)
|
||||
const cardData = newData.get(currentImage.id)
|
||||
if (cardData) {
|
||||
newData.set(currentImage.id, {
|
||||
...cardData,
|
||||
[field]: value,
|
||||
})
|
||||
}
|
||||
return newData
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!currentCard) return
|
||||
|
||||
if (!currentCard.original.trim() || !currentCard.translation.trim() || !currentCard.mnemo.trim()) {
|
||||
toast.error('Original, translation and mnemo are required')
|
||||
return
|
||||
}
|
||||
|
||||
const image = images.find((img) => img.id === currentCard.imageId)
|
||||
if (!image) return
|
||||
|
||||
const cardData: GameCardDto = {
|
||||
id: currentCard.cardId || null,
|
||||
packId: currentCard.packId || undefined,
|
||||
original: currentCard.original.trim(),
|
||||
translation: currentCard.translation.trim(),
|
||||
mnemo: currentCard.mnemo.trim(),
|
||||
transcription: currentCard.transcription.trim() || undefined,
|
||||
transcriptionMnemo: currentCard.transcriptionMnemo.trim() || undefined,
|
||||
back: currentCard.back.trim() || undefined,
|
||||
image: image.objectId, // Use objectId instead of base64
|
||||
imageBack: currentCard.imageBack || undefined,
|
||||
}
|
||||
|
||||
updateMutation.mutate(cardData)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex < images.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const savedCount = Array.from(cardsData.values()).filter((card) => card.isSaved).length
|
||||
const totalCount = images.length
|
||||
|
||||
// Wait for images and cards data to be initialized
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground">No images uploaded</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentImage || !currentCard) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground">Initializing card data...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Fill Card Details</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Card {currentIndex + 1} of {totalCount} • {savedCount} saved
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={currentCard.isSaved ? 'default' : 'secondary'}>
|
||||
{currentCard.isSaved ? 'Saved' : 'Not Saved'}
|
||||
</Badge>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Image Preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Image Preview</CardTitle>
|
||||
<CardDescription>{currentImage.file?.name || 'Unknown'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
|
||||
{currentImage.presignedUrl || currentImage.preview ? (
|
||||
<img
|
||||
src={currentImage.presignedUrl || currentImage.preview}
|
||||
alt={currentImage.file?.name || 'Card image'}
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
console.error('Failed to load image:', currentImage.presignedUrl || currentImage.preview)
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
Image preview not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Information</CardTitle>
|
||||
<CardDescription>Fill in the details for this card</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="packId">Pack ID (optional)</Label>
|
||||
<Select
|
||||
value={currentCard.packId || undefined}
|
||||
onValueChange={(value: string) => {
|
||||
// If "none" is selected, clear the packId
|
||||
const packId = value === '__none__' ? '' : value
|
||||
handleFieldChange('packId', packId)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a pack (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{packsData && packsData.length > 0 ? (
|
||||
packsData.map((pack) => (
|
||||
<SelectItem key={pack.id} value={pack.id}>
|
||||
{pack.title || pack.id}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="original">Original *</Label>
|
||||
<Input
|
||||
id="original"
|
||||
value={currentCard.original}
|
||||
onChange={(e) => handleFieldChange('original', e.target.value)}
|
||||
placeholder="e.g. cerdo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="translation">Translation *</Label>
|
||||
<Input
|
||||
id="translation"
|
||||
value={currentCard.translation}
|
||||
onChange={(e) => handleFieldChange('translation', e.target.value)}
|
||||
placeholder="e.g. pig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mnemo">Mnemo *</Label>
|
||||
<Input
|
||||
id="mnemo"
|
||||
value={currentCard.mnemo}
|
||||
onChange={(e) => handleFieldChange('mnemo', e.target.value)}
|
||||
placeholder="e.g. [pig] with heart"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="transcription">Transcription</Label>
|
||||
<Input
|
||||
id="transcription"
|
||||
value={currentCard.transcription}
|
||||
onChange={(e) => handleFieldChange('transcription', e.target.value)}
|
||||
placeholder="e.g. sɛrdo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="transcriptionMnemo">Transcription Mnemo</Label>
|
||||
<Input
|
||||
id="transcriptionMnemo"
|
||||
value={currentCard.transcriptionMnemo}
|
||||
onChange={(e) => handleFieldChange('transcriptionMnemo', e.target.value)}
|
||||
placeholder="e.g. pig with heart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="back">Back Side</Label>
|
||||
<Textarea
|
||||
id="back"
|
||||
value={currentCard.back}
|
||||
onChange={(e) => handleFieldChange('back', e.target.value)}
|
||||
placeholder="Additional information on the back of the card"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{updateMutation.isPending ? 'Saving...' : currentCard.isSaved ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === images.length - 1}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>{savedCount} / {totalCount} saved</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${(savedCount / totalCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{savedCount === totalCount && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={onComplete}>
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { AudioUpload } from '@/components/ui/audio-upload'
|
||||
import { CardVoicesManager } from '@/components/CardVoicesManager'
|
||||
|
||||
interface CardEditorPreviewProps {
|
||||
formData: {
|
||||
packId: string
|
||||
original: string
|
||||
translation: string
|
||||
mnemo: string
|
||||
transcription: string
|
||||
transcriptionMnemo: string
|
||||
back: string
|
||||
image: string | undefined
|
||||
imageBack: string | undefined
|
||||
voice: string | undefined
|
||||
voiceLanguage: string
|
||||
}
|
||||
onFormDataChange: (updates: Partial<CardEditorPreviewProps['formData']>) => void
|
||||
cardId?: string
|
||||
disabled?: boolean
|
||||
packColor?: string
|
||||
}
|
||||
|
||||
// Helper to get image source
|
||||
// Note: For UUID (objectId), returns undefined - ImageUpload component will handle fetching presigned URL
|
||||
function getImageSrc(image?: string): string | undefined {
|
||||
if (!image) return undefined
|
||||
|
||||
// If it's already a data URL or http/https URL, return as is
|
||||
if (image.startsWith('data:') || image.startsWith('http://') || image.startsWith('https://')) {
|
||||
return image
|
||||
}
|
||||
|
||||
// If it's a relative API path, prepend the base URL
|
||||
if (image.startsWith('/api/')) {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online'
|
||||
return `${baseUrl}${image}`
|
||||
}
|
||||
|
||||
// If it's a UUID (objectId), return undefined - ImageUpload will fetch presigned URL
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(image)
|
||||
if (isUuid) {
|
||||
return undefined // Will be handled by ImageUpload component's useEffect
|
||||
}
|
||||
|
||||
// Otherwise, assume it's base64 and add the data URL prefix
|
||||
return `data:image/png;base64,${image}`
|
||||
}
|
||||
|
||||
export function CardEditorPreview({
|
||||
formData,
|
||||
onFormDataChange,
|
||||
cardId,
|
||||
disabled = false,
|
||||
packColor = '#6b7280', // Default gray border color
|
||||
}: CardEditorPreviewProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* Cards Preview - Front and Back side by side */}
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Front side */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-center text-muted-foreground">Front Side</div>
|
||||
<div
|
||||
className="rounded-3xl shadow-lg"
|
||||
style={{
|
||||
borderColor: packColor,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: '3px',
|
||||
borderStyle: 'solid',
|
||||
aspectRatio: '0.66',
|
||||
minHeight: '400px',
|
||||
maxHeight: '600px',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
|
||||
{/* Top section: Original, Translation */}
|
||||
<div className="w-full px-6 pt-6 pb-4 flex-shrink-0">
|
||||
{/* Original - large, bold */}
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
value={formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ original: e.target.value })
|
||||
}
|
||||
placeholder="Original text"
|
||||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Translation - smaller, gray */}
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
value={formData.translation}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ translation: e.target.value })
|
||||
}
|
||||
placeholder="Translation"
|
||||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: 400,
|
||||
color: formData.translation ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image in the middle */}
|
||||
<div className="flex-1 bg-gray-50 flex items-center justify-center relative min-h-0">
|
||||
{getImageSrc(formData.image) ? (
|
||||
<img
|
||||
src={getImageSrc(formData.image)}
|
||||
alt="Card"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400">
|
||||
<svg
|
||||
className="w-24 h-24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mnemo phrase at the bottom */}
|
||||
<div className="w-full px-6 py-6 bg-white flex-shrink-0">
|
||||
<div className="text-center min-h-[60px] flex items-center justify-center">
|
||||
<Input
|
||||
value={formData.mnemo}
|
||||
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
|
||||
placeholder="Mnemo phrase"
|
||||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent w-full"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back side */}
|
||||
<div
|
||||
className="rounded-3xl shadow-lg"
|
||||
style={{
|
||||
borderColor: packColor,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: '3px',
|
||||
borderStyle: 'solid',
|
||||
aspectRatio: '0.66',
|
||||
minHeight: '400px',
|
||||
maxHeight: '600px',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
|
||||
{formData.imageBack ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-50 min-h-0">
|
||||
<img
|
||||
src={getImageSrc(formData.imageBack)}
|
||||
alt="Back"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 bg-white flex-shrink-0">
|
||||
<Textarea
|
||||
value={formData.back || formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ back: e.target.value })
|
||||
}
|
||||
placeholder="Back side text"
|
||||
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '1.2',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<Textarea
|
||||
value={formData.back || formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ back: e.target.value })
|
||||
}
|
||||
placeholder="Back side text"
|
||||
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '1.2',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit fields below the preview */}
|
||||
<div className="w-full max-w-5xl space-y-4 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Original *</label>
|
||||
<Input
|
||||
value={formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ original: e.target.value })
|
||||
}
|
||||
placeholder="e.g. cerdo"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Translation *</label>
|
||||
<Input
|
||||
value={formData.translation}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ translation: e.target.value })
|
||||
}
|
||||
placeholder="e.g. pig"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Mnemo *</label>
|
||||
<Input
|
||||
value={formData.mnemo}
|
||||
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
|
||||
placeholder="e.g. [pig] with heart"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Back Side</label>
|
||||
<Textarea
|
||||
value={formData.back}
|
||||
onChange={(e) => onFormDataChange({ back: e.target.value })}
|
||||
placeholder="Additional information on the back of the card"
|
||||
rows={3}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Front Image"
|
||||
value={formData.image}
|
||||
onChange={(value) => onFormDataChange({ image: value })}
|
||||
disabled={disabled}
|
||||
uploadType="card-image"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Back Image"
|
||||
value={formData.imageBack}
|
||||
onChange={(value) => onFormDataChange({ imageBack: value })}
|
||||
disabled={disabled}
|
||||
uploadType="card-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Transcription</label>
|
||||
<Input
|
||||
value={formData.transcription}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ transcription: e.target.value })
|
||||
}
|
||||
placeholder="e.g. sɛrdo"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Transcription Mnemo</label>
|
||||
<Input
|
||||
value={formData.transcriptionMnemo}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ transcriptionMnemo: e.target.value })
|
||||
}
|
||||
placeholder="e.g. pig with heart"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<AudioUpload
|
||||
label="Voice (will be added on Save)"
|
||||
value={formData.voice}
|
||||
onChange={(voice) => onFormDataChange({ voice })}
|
||||
language={formData.voiceLanguage}
|
||||
onLanguageChange={(voiceLanguage) =>
|
||||
onFormDataChange({ voiceLanguage })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выберите аудиофайл — он будет загружен и привязан к карточке при
|
||||
нажатии Create/Update.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Voice Controls - only show if cardId exists */}
|
||||
{cardId && (
|
||||
<div className="space-y-2">
|
||||
<CardVoicesManager cardId={cardId} disabled={disabled} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { voicesApi, type VoiceDto } from '@/api/voices'
|
||||
import { AudioUpload } from '@/components/ui/audio-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X, Plus, Music, Play, Pause } from 'lucide-react'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
interface CardVoicesManagerProps {
|
||||
cardId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManagerProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
|
||||
const [newLanguage, setNewLanguage] = useState('en')
|
||||
|
||||
// Load voices for the card
|
||||
const { data: voicesData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['cardVoices', cardId],
|
||||
queryFn: async () => {
|
||||
console.log('🔍 CardVoicesManager: Fetching voices for card', cardId)
|
||||
const result = await voicesApi.getCardVoices(cardId)
|
||||
console.log('✅ CardVoicesManager: Fetched voices', result)
|
||||
return result
|
||||
},
|
||||
// Voices should still load even if parent form is disabled (e.g. during save)
|
||||
enabled: !!cardId,
|
||||
})
|
||||
|
||||
// Add voice mutation
|
||||
const addVoiceMutation = useMutation({
|
||||
mutationFn: ({ voiceUrl, language }: { voiceUrl: string; language: string }) => {
|
||||
console.log('🔍 CardVoicesManager: addVoiceMutation.mutationFn called', {
|
||||
cardId,
|
||||
voiceUrl,
|
||||
language,
|
||||
})
|
||||
return voicesApi.addCardVoice(cardId, voiceUrl, language)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
console.log('✅ CardVoicesManager: addVoiceMutation.onSuccess')
|
||||
|
||||
// Invalidate and refetch to ensure we get the latest data
|
||||
await queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
|
||||
|
||||
// Force refetch to ensure the list is updated
|
||||
const refetchResult = await refetch()
|
||||
console.log('✅ CardVoicesManager: Refetched voices after add', refetchResult.data)
|
||||
|
||||
toast.success('Voice added successfully')
|
||||
|
||||
// Auto-close form and reset after successful addition
|
||||
setShowAddForm(false)
|
||||
setNewAudio(undefined)
|
||||
setNewLanguage('en')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('❌ CardVoicesManager: addVoiceMutation.onError', error)
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to add voice')
|
||||
},
|
||||
})
|
||||
|
||||
// Remove voice mutation
|
||||
const removeVoiceMutation = useMutation({
|
||||
mutationFn: (voiceId: string) => voicesApi.removeCardVoice(cardId, voiceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
|
||||
toast.success('Voice removed successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to remove voice')
|
||||
},
|
||||
})
|
||||
|
||||
// handleAddVoice is no longer needed - voice is added automatically after upload
|
||||
|
||||
const handleRemoveVoice = (voiceId: string) => {
|
||||
if (confirm('Are you sure you want to remove this voice?')) {
|
||||
removeVoiceMutation.mutate(voiceId)
|
||||
}
|
||||
}
|
||||
|
||||
const voices = voicesData?.items || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Voices ({voices.length})</Label>
|
||||
{!showAddForm && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Voice
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add voice form */}
|
||||
{showAddForm && (
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<div className="space-y-4">
|
||||
<AudioUpload
|
||||
label="Audio File"
|
||||
value={newAudio}
|
||||
onChange={async (value) => {
|
||||
console.log('🔍 CardVoicesManager: AudioUpload onChange', { value, cardId })
|
||||
setNewAudio(value)
|
||||
|
||||
// Automatically add voice after upload
|
||||
if (value && cardId) {
|
||||
console.log('✅ CardVoicesManager: Auto-adding voice after upload', {
|
||||
cardId,
|
||||
voiceUrl: value,
|
||||
language: newLanguage,
|
||||
})
|
||||
|
||||
addVoiceMutation.mutate({
|
||||
voiceUrl: value,
|
||||
language: newLanguage,
|
||||
})
|
||||
}
|
||||
}}
|
||||
language={newLanguage}
|
||||
onLanguageChange={setNewLanguage}
|
||||
disabled={disabled || addVoiceMutation.isPending}
|
||||
/>
|
||||
{addVoiceMutation.isPending && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Adding voice...
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddForm(false)
|
||||
setNewAudio(undefined)
|
||||
setNewLanguage('en')
|
||||
}}
|
||||
disabled={disabled || addVoiceMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voices list */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
No voices added yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{voices.map((voice) => (
|
||||
<VoiceItem
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
onRemove={() => handleRemoveVoice(voice.id)}
|
||||
disabled={disabled || removeVoiceMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface VoiceItemProps {
|
||||
voice: VoiceDto
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function VoiceItem({ voice, onRemove, disabled }: VoiceItemProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.current) {
|
||||
try {
|
||||
const audio = new Audio(`data:audio/mpeg;base64,${voice.voiceUrl}`)
|
||||
audioRef.current = audio
|
||||
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.onerror = (e) => {
|
||||
console.error('Audio playback error:', e)
|
||||
toast.error('Failed to play audio. The file may be corrupted or in an unsupported format.')
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.onloadstart = () => {
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
audio.play().catch((error) => {
|
||||
console.error('Audio play error:', error)
|
||||
toast.error('Failed to play audio. Please check your browser audio settings.')
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating audio element:', error)
|
||||
toast.error('Failed to initialize audio player')
|
||||
setIsPlaying(false)
|
||||
}
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
audioRef.current.play().catch((error) => {
|
||||
console.error('Audio play error:', error)
|
||||
toast.error('Failed to resume audio playback')
|
||||
})
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between p-3 border rounded-lg transition-colors ${
|
||||
isPlaying ? 'bg-primary/5 border-primary/20' : ''
|
||||
}`}>
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<Music className={`h-5 w-5 flex-shrink-0 ${
|
||||
isPlaying ? 'text-primary' : 'text-muted-foreground'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium">Voice</span>
|
||||
<Badge variant="outline">{voice.language}</Badge>
|
||||
{isPlaying && (
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
Playing
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Added {new Date(voice.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isPlaying ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={handlePlayPause}
|
||||
disabled={disabled}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Play</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { cardsApi } from '@/api/cards'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, Check } from 'lucide-react'
|
||||
|
||||
interface PackCardsManagerProps {
|
||||
currentCardIds: string[]
|
||||
onCardsChange: (addIds: string[], removeIds: string[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function PackCardsManager({
|
||||
currentCardIds,
|
||||
onCardsChange,
|
||||
disabled = false,
|
||||
}: PackCardsManagerProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
|
||||
const [removedCards, setRemovedCards] = useState<Set<string>>(new Set())
|
||||
|
||||
// Load all cards with search
|
||||
const { data: cardsData, isLoading } = useQuery({
|
||||
queryKey: ['cards', 1, 100, search], // Limit to 100 (backend max)
|
||||
queryFn: () => cardsApi.getCards({ page: 1, limit: 100, search }),
|
||||
enabled: !disabled,
|
||||
})
|
||||
|
||||
// Initialize selected cards from currentCardIds when it changes
|
||||
useEffect(() => {
|
||||
const initialSelected = new Set<string>(
|
||||
currentCardIds.filter((id) => !removedCards.has(id.toString()))
|
||||
)
|
||||
setSelectedCards(initialSelected)
|
||||
// Reset removed cards when currentCardIds changes (e.g., when opening dialog)
|
||||
setRemovedCards(new Set())
|
||||
}, [currentCardIds.join(',')]) // Use join to detect array changes
|
||||
|
||||
// Calculate which cards to add/remove when selection changes
|
||||
useEffect(() => {
|
||||
const currentlySelected = Array.from(selectedCards)
|
||||
const removed = Array.from(removedCards)
|
||||
|
||||
// Cards to add: selected but not in currentCardIds and not removed
|
||||
const toAdd = currentlySelected.filter(
|
||||
(id) => !currentCardIds.includes(id) && !removed.includes(id)
|
||||
)
|
||||
// Cards to remove: in removedCards
|
||||
const toRemove = removed.filter((id) => currentCardIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
onCardsChange(toAdd, toRemove)
|
||||
} else if (selectedCards.size > 0 || removedCards.size > 0) {
|
||||
// Also notify if selection was cleared
|
||||
onCardsChange([], [])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCards.size, removedCards.size, currentCardIds.join(',')])
|
||||
|
||||
const handleToggleCard = (cardId: string | number) => {
|
||||
if (disabled) return
|
||||
|
||||
const cardIdStr = String(cardId)
|
||||
const isCurrentlySelected = selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
|
||||
const isInCurrentPack = currentCardIds.includes(cardIdStr)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// Deselect card
|
||||
const newSelected = new Set(selectedCards)
|
||||
newSelected.delete(cardIdStr)
|
||||
setSelectedCards(newSelected)
|
||||
|
||||
// If it was in current pack, mark as removed
|
||||
if (isInCurrentPack) {
|
||||
setRemovedCards((prev) => new Set([...prev, cardIdStr]))
|
||||
}
|
||||
} else {
|
||||
// Select card
|
||||
const newSelected = new Set(selectedCards)
|
||||
newSelected.add(cardIdStr)
|
||||
setSelectedCards(newSelected)
|
||||
|
||||
// If it was marked as removed, unmark it
|
||||
if (removedCards.has(cardIdStr)) {
|
||||
setRemovedCards((prev) => {
|
||||
const newRemoved = new Set(prev)
|
||||
newRemoved.delete(cardIdStr)
|
||||
return newRemoved
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isCardSelected = (cardId: string | number) => {
|
||||
const cardIdStr = String(cardId)
|
||||
return selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
|
||||
}
|
||||
|
||||
const isCardInCurrentPack = (cardId: string | number) => {
|
||||
return currentCardIds.includes(String(cardId))
|
||||
}
|
||||
|
||||
const allCards = cardsData?.items || []
|
||||
const filteredCards = search
|
||||
? allCards.filter(
|
||||
(card) =>
|
||||
card.original?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
card.translation?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
card.mnemo?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: allCards
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Manage Cards in Pack</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search cards by original, translation, or mnemo..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected cards: {selectedCards.size - removedCards.size} / {allCards.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4">Loading cards...</div>
|
||||
) : (
|
||||
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Original</TableHead>
|
||||
<TableHead>Translation</TableHead>
|
||||
<TableHead>Mnemo</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCards.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No cards found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredCards.map((card) => {
|
||||
const cardIdStr = card.id ? String(card.id) : `temp-${card.original}`
|
||||
const selected = isCardSelected(cardIdStr)
|
||||
const inCurrentPack = isCardInCurrentPack(cardIdStr)
|
||||
const newlyRemoved = removedCards.has(cardIdStr)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={card.id || `temp-${card.original}`}
|
||||
className={selected ? 'bg-muted/50' : ''}
|
||||
onClick={() => handleToggleCard(cardIdStr)}
|
||||
>
|
||||
<TableCell>
|
||||
{selected ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<div className="h-4 w-4 border rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{card.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{card.original}</TableCell>
|
||||
<TableCell>{card.translation}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
|
||||
<TableCell>
|
||||
{newlyRemoved ? (
|
||||
<Badge variant="destructive">Removed</Badge>
|
||||
) : selected && inCurrentPack ? (
|
||||
<Badge variant="default">In Pack</Badge>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary">Selected</Badge>
|
||||
) : inCurrentPack ? (
|
||||
<Badge variant="outline">In Pack</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { testsApi } from '@/api/tests'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, Check } from 'lucide-react'
|
||||
|
||||
interface PackTestsManagerProps {
|
||||
currentTestIds: string[]
|
||||
onTestsChange: (addIds: string[], removeIds: string[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function PackTestsManager({
|
||||
currentTestIds,
|
||||
onTestsChange,
|
||||
disabled = false,
|
||||
}: PackTestsManagerProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTests, setSelectedTests] = useState<Set<string>>(new Set())
|
||||
const [removedTests, setRemovedTests] = useState<Set<string>>(new Set())
|
||||
|
||||
// Load all tests with search
|
||||
const { data: testsData, isLoading } = useQuery({
|
||||
queryKey: ['tests', 1, 100, search], // Limit to 100 (backend max)
|
||||
queryFn: () => testsApi.getTests({ page: 1, limit: 100, search }),
|
||||
enabled: !disabled,
|
||||
})
|
||||
|
||||
// Initialize selected tests from currentTestIds when it changes
|
||||
useEffect(() => {
|
||||
const initialSelected = new Set<string>(
|
||||
currentTestIds.filter((id) => !removedTests.has(id.toString()))
|
||||
)
|
||||
setSelectedTests(initialSelected)
|
||||
// Reset removed tests when currentTestIds changes (e.g., when opening dialog)
|
||||
setRemovedTests(new Set())
|
||||
}, [currentTestIds.join(',')]) // Use join to detect array changes
|
||||
|
||||
// Calculate which tests to add/remove when selection changes
|
||||
useEffect(() => {
|
||||
const currentlySelected = Array.from(selectedTests)
|
||||
const removed = Array.from(removedTests)
|
||||
|
||||
// Tests to add: selected but not in currentTestIds and not removed
|
||||
const toAdd = currentlySelected.filter(
|
||||
(id) => !currentTestIds.includes(id) && !removed.includes(id)
|
||||
)
|
||||
// Tests to remove: in removedTests
|
||||
const toRemove = removed.filter((id) => currentTestIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
onTestsChange(toAdd, toRemove)
|
||||
} else if (selectedTests.size > 0 || removedTests.size > 0) {
|
||||
// Also notify if selection was cleared
|
||||
onTestsChange([], [])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTests.size, removedTests.size, currentTestIds.join(',')])
|
||||
|
||||
const handleToggleTest = (testId: string) => {
|
||||
if (disabled || !testId) return
|
||||
|
||||
const testIdStr = String(testId)
|
||||
const isCurrentlySelected = selectedTests.has(testIdStr) && !removedTests.has(testIdStr)
|
||||
const isInCurrentPack = currentTestIds.includes(testIdStr)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// Deselect test
|
||||
const newSelected = new Set(selectedTests)
|
||||
newSelected.delete(testIdStr)
|
||||
setSelectedTests(newSelected)
|
||||
|
||||
// If it was in current pack, mark as removed
|
||||
if (isInCurrentPack) {
|
||||
setRemovedTests((prev) => new Set([...prev, testIdStr]))
|
||||
}
|
||||
} else {
|
||||
// Select test
|
||||
const newSelected = new Set(selectedTests)
|
||||
newSelected.add(testIdStr)
|
||||
setSelectedTests(newSelected)
|
||||
|
||||
// If it was marked as removed, unmark it
|
||||
if (removedTests.has(testIdStr)) {
|
||||
setRemovedTests((prev) => {
|
||||
const newRemoved = new Set(prev)
|
||||
newRemoved.delete(testIdStr)
|
||||
return newRemoved
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isTestSelected = (testId: string | number) => {
|
||||
const testIdStr = String(testId)
|
||||
return selectedTests.has(testIdStr) && !removedTests.has(testIdStr)
|
||||
}
|
||||
|
||||
const isTestInCurrentPack = (testId: string | number) => {
|
||||
return currentTestIds.includes(String(testId))
|
||||
}
|
||||
|
||||
const allTests = testsData?.items || []
|
||||
const filteredTests = search
|
||||
? allTests.filter(
|
||||
(test) =>
|
||||
test.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
test.id?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: allTests
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Manage Tests in Pack</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tests by name or ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected tests: {selectedTests.size - removedTests.size} / {allTests.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4">Loading tests...</div>
|
||||
) : (
|
||||
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Questions</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No tests found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredTests
|
||||
.filter((test) => test.id) // Only show tests with IDs
|
||||
.map((test) => {
|
||||
const testIdStr = String(test.id!)
|
||||
const selected = isTestSelected(testIdStr)
|
||||
const inCurrentPack = isTestInCurrentPack(testIdStr)
|
||||
const newlyRemoved = removedTests.has(testIdStr)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={test.id}
|
||||
className={selected ? 'bg-muted/50' : ''}
|
||||
onClick={() => handleToggleTest(testIdStr)}
|
||||
>
|
||||
<TableCell>
|
||||
{selected ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<div className="h-4 w-4 border rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{test.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{test.name}</TableCell>
|
||||
<TableCell>
|
||||
{typeof test.questions === 'number'
|
||||
? test.questions
|
||||
: test.questions?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell>{test.version || 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
{newlyRemoved ? (
|
||||
<Badge variant="destructive">Removed</Badge>
|
||||
) : selected && inCurrentPack ? (
|
||||
<Badge variant="default">In Pack</Badge>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary">Selected</Badge>
|
||||
) : inCurrentPack ? (
|
||||
<Badge variant="outline">In Pack</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -20,9 +20,7 @@ interface LayoutProps {
|
|||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Cards', href: '/cards', icon: FileText },
|
||||
{ name: 'Packs', href: '/packs', icon: Package },
|
||||
{ name: 'Tests', href: '/tests', icon: ClipboardList },
|
||||
{ name: 'Users', href: '/users', icon: Users },
|
||||
]
|
||||
|
||||
|
|
@ -56,7 +54,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-gray-900">Mnemo Admin</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900">Sto k Odnomu Admin</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -116,7 +114,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Mnemo Cards Admin</h1>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Sto k Odnomu Admin</h1>
|
||||
<div className="w-10" /> {/* Spacer */}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,721 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||
import { upsertCardWithOptionalVoice } from '@/api/cardsWithVoice'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { BulkCardUpload } from '@/components/BulkCardUpload'
|
||||
import { BulkCardEditor } from '@/components/BulkCardEditor'
|
||||
import { CardEditorPreview } from '@/components/CardEditorPreview'
|
||||
import { packsApi } from '@/api/packs'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, List, Grid } from 'lucide-react'
|
||||
|
||||
type ViewMode = 'list' | 'grid'
|
||||
|
||||
export default function CardsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [selectedCard, setSelectedCard] = useState<GameCardDto | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [cardToDelete, setCardToDelete] = useState<GameCardDto | null>(null)
|
||||
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false)
|
||||
const [isBulkEditorOpen, setIsBulkEditorOpen] = useState(false)
|
||||
const [uploadedImages, setUploadedImages] = useState<Array<{
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
objectId: string // Object ID in MinIO (UUID)
|
||||
presignedUrl?: string // Presigned URL for preview
|
||||
}>>([])
|
||||
const [bulkUploadPackId, setBulkUploadPackId] = useState<string | undefined>(undefined)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
packId: '',
|
||||
original: '',
|
||||
translation: '',
|
||||
mnemo: '',
|
||||
transcription: '',
|
||||
transcriptionMnemo: '',
|
||||
back: '',
|
||||
image: undefined as string | undefined,
|
||||
imageBack: undefined as string | undefined,
|
||||
voice: undefined as string | undefined,
|
||||
voiceLanguage: 'en',
|
||||
})
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch cards
|
||||
const { data, isLoading, error } = useQuery<PaginatedResponse<GameCardDto>>({
|
||||
queryKey: ['cards', page, search],
|
||||
queryFn: () => cardsApi.getCards({ page, limit, search }),
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on client errors (4xx)
|
||||
if (isCardsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: { card: GameCardDto; voice?: { voiceUrl: string; language: string } }) =>
|
||||
upsertCardWithOptionalVoice(payload.card, payload.voice),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('Card created successfully')
|
||||
closeDialog()
|
||||
// Update selectedCard with the new ID from response
|
||||
if (response?.card?.id) {
|
||||
setSelectedCard(response.card)
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'create', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error creating card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: { card: GameCardDto; voice?: { voiceUrl: string; language: string } }) =>
|
||||
upsertCardWithOptionalVoice(payload.card, payload.voice),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('Card updated successfully')
|
||||
closeDialog()
|
||||
// Update selectedCard with the updated card from response
|
||||
if (response?.card) {
|
||||
setSelectedCard(response.card)
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'update', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error updating card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (cardId: string) => cardsApi.deleteCard(cardId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('Card deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setCardToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'delete', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error deleting card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch packs for packId selection
|
||||
const { data: packsData } = useQuery({
|
||||
queryKey: ['packs', 1, 100, ''],
|
||||
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }),
|
||||
})
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setSelectedCard(null)
|
||||
setFormData({
|
||||
packId: '',
|
||||
original: '',
|
||||
translation: '',
|
||||
mnemo: '',
|
||||
transcription: '',
|
||||
transcriptionMnemo: '',
|
||||
back: '',
|
||||
image: undefined,
|
||||
imageBack: undefined,
|
||||
voice: undefined,
|
||||
voiceLanguage: 'en',
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (card: GameCardDto) => {
|
||||
setSelectedCard(card)
|
||||
setFormData({
|
||||
packId: card.packId ?? '',
|
||||
original: card.original || '',
|
||||
translation: card.translation || '',
|
||||
mnemo: card.mnemo || '',
|
||||
transcription: card.transcription || '',
|
||||
transcriptionMnemo: card.transcriptionMnemo || '',
|
||||
back: card.back || '',
|
||||
image: card.image,
|
||||
imageBack: card.imageBack,
|
||||
voice: undefined,
|
||||
voiceLanguage: 'en',
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false)
|
||||
setSelectedCard(null)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.original.trim() || !formData.translation.trim() || !formData.mnemo.trim()) {
|
||||
toast.error('Original, translation and mnemo are required')
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if this is an update or create
|
||||
// Check if id exists and is not empty (handle both string and number)
|
||||
const idStr = selectedCard?.id != null ? String(selectedCard.id) : ''
|
||||
const hasValidId = idStr !== '' && idStr.trim() !== '' && idStr !== '0'
|
||||
const isUpdate = selectedCard !== null && hasValidId
|
||||
|
||||
const cardData: GameCardDto = {
|
||||
id: isUpdate ? String(selectedCard.id) : null,
|
||||
packId: formData.packId.trim() || undefined,
|
||||
original: formData.original.trim(),
|
||||
translation: formData.translation.trim(),
|
||||
mnemo: formData.mnemo.trim(),
|
||||
transcription: formData.transcription.trim() || undefined,
|
||||
transcriptionMnemo: formData.transcriptionMnemo.trim() || undefined,
|
||||
back: formData.back.trim() || undefined,
|
||||
image: formData.image || undefined,
|
||||
imageBack: formData.imageBack || undefined,
|
||||
}
|
||||
|
||||
const voice =
|
||||
formData.voice && formData.voice.trim()
|
||||
? { voiceUrl: formData.voice, language: formData.voiceLanguage || 'en' }
|
||||
: undefined
|
||||
|
||||
if (isUpdate) {
|
||||
updateMutation.mutate({ card: cardData, voice })
|
||||
} else {
|
||||
createMutation.mutate({ card: cardData, voice })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (card: GameCardDto) => {
|
||||
setCardToDelete(card)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (cardToDelete && cardToDelete.id != null && cardToDelete.id !== '') {
|
||||
// Ensure id is a string
|
||||
const cardId = String(cardToDelete.id)
|
||||
deleteMutation.mutate(cardId)
|
||||
} else {
|
||||
toast.error('Invalid card ID')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
setPage(1) // Reset to first page when searching
|
||||
}
|
||||
|
||||
// Group cards by pack
|
||||
const groupedCards = data?.items.reduce((acc, card) => {
|
||||
const packId = card.packId || '__no_pack__'
|
||||
if (!acc[packId]) {
|
||||
acc[packId] = []
|
||||
}
|
||||
acc[packId].push(card)
|
||||
return acc
|
||||
}, {} as Record<string, GameCardDto[]>) || {}
|
||||
|
||||
// Get pack name by ID
|
||||
const getPackName = (packId: string): string => {
|
||||
if (packId === '__no_pack__') {
|
||||
return 'No Pack'
|
||||
}
|
||||
const pack = packsData?.items.find(p => p.id === packId)
|
||||
return pack?.title || packId
|
||||
}
|
||||
|
||||
// Get pack color by ID
|
||||
const getPackColor = (packId: string): string | undefined => {
|
||||
if (!packId || packId === '__no_pack__') {
|
||||
return undefined
|
||||
}
|
||||
const pack = packsData?.items.find(p => p.id === packId)
|
||||
return pack?.color
|
||||
}
|
||||
|
||||
// Get image source - handles presigned URLs, base64, and legacy URLs
|
||||
const getImageSrc = (card: GameCardDto, isBack = false): string | undefined => {
|
||||
// Prefer presigned URL from backend if available
|
||||
const presignedUrl = isBack ? card.imageBackUrl : card.imageUrl
|
||||
if (presignedUrl) {
|
||||
return presignedUrl
|
||||
}
|
||||
|
||||
// Fallback to objectId/image field
|
||||
const image = isBack ? card.imageBack : card.image
|
||||
if (!image) return undefined
|
||||
|
||||
// If it's already a data URL or http/https URL, return as is
|
||||
if (image.startsWith('data:') || image.startsWith('http://') || image.startsWith('https://')) {
|
||||
return image
|
||||
}
|
||||
|
||||
// If it's a relative API path, prepend the base URL
|
||||
if (image.startsWith('/api/')) {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online'
|
||||
return `${baseUrl}${image}`
|
||||
}
|
||||
|
||||
// If it's a UUID (objectId), we need to fetch presigned URL
|
||||
// For now, return undefined - ImageUpload component will handle fetching
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(image)
|
||||
if (isUuid) {
|
||||
return undefined // Will be handled by ImageUpload component
|
||||
}
|
||||
|
||||
// Otherwise, assume it's base64 and add the data URL prefix
|
||||
return `data:image/png;base64,${image}`
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Cards Management</h1>
|
||||
<p className="text-muted-foreground">Error loading cards</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-500 font-medium">Failed to load cards</p>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['cards'] })}
|
||||
className="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Cards Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View, create, edit and delete game cards
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" onClick={() => setIsBulkUploadOpen(true)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search cards..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cards Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>All Cards ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>
|
||||
Manage game cards in the system
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading cards...</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'list' ? (
|
||||
<>
|
||||
{Object.entries(groupedCards).map(([packId, cards]) => (
|
||||
<div key={packId} className="mb-8">
|
||||
<div className="bg-muted/50 px-4 py-3 rounded-t-lg border-b-2 border-primary/20 mb-0">
|
||||
<h3 className="text-xl font-bold">
|
||||
{getPackName(packId)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{cards.length} {cards.length === 1 ? 'card' : 'cards'}
|
||||
</p>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Original</TableHead>
|
||||
<TableHead>Translation</TableHead>
|
||||
<TableHead>Mnemo</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cards.map((card) => (
|
||||
<TableRow key={card.id || `temp-${card.original}`}>
|
||||
<TableCell>{card.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{card.original}</TableCell>
|
||||
<TableCell>{card.translation}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(card)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(card)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(groupedCards).map(([packId, cards]) => (
|
||||
<div key={packId} className="mb-8">
|
||||
<div className="bg-muted/50 px-4 py-3 rounded-lg border-b-2 border-primary/20 mb-4">
|
||||
<h3 className="text-xl font-bold">
|
||||
{getPackName(packId)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{cards.length} {cards.length === 1 ? 'card' : 'cards'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id || `temp-${card.original}`}
|
||||
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => openEditDialog(card)}
|
||||
>
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{getImageSrc(card) ? (
|
||||
<img
|
||||
src={getImageSrc(card)}
|
||||
alt={card.original}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEditDialog(card)
|
||||
}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(card)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="font-medium text-sm truncate" title={card.original}>
|
||||
{card.original}
|
||||
</p>
|
||||
{card.translation && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={card.translation}>
|
||||
{card.translation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} cards
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[95vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedCard ? 'Edit Card' : 'Create New Card'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedCard ? 'Update the card information' : 'Add a new card to the system'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="packId">Pack ID (optional)</Label>
|
||||
<Select
|
||||
value={formData.packId || undefined}
|
||||
onValueChange={(value: string) => {
|
||||
// If "none" is selected, clear the packId
|
||||
const packId = value === '__none__' ? '' : value
|
||||
setFormData(prev => ({ ...prev, packId }))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a pack (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{packsData?.items && packsData.items.length > 0 ? (
|
||||
packsData.items.map((pack) => (
|
||||
<SelectItem key={pack.id} value={pack.id}>
|
||||
{pack.title || pack.id}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<CardEditorPreview
|
||||
formData={formData}
|
||||
onFormDataChange={(updates) => setFormData(prev => ({ ...prev, ...updates }))}
|
||||
cardId={selectedCard?.id ? String(selectedCard.id) : undefined}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
packColor={getPackColor(formData.packId)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : (selectedCard ? 'Update' : 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Card</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the card "{cardToDelete?.original}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Bulk Upload Dialog */}
|
||||
{isBulkUploadOpen && !isBulkEditorOpen && (
|
||||
<Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<BulkCardUpload
|
||||
onImagesUploaded={(images, packId) => {
|
||||
setUploadedImages(images)
|
||||
setBulkUploadPackId(packId)
|
||||
setIsBulkUploadOpen(false)
|
||||
setIsBulkEditorOpen(true)
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsBulkUploadOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Bulk Editor Dialog */}
|
||||
{isBulkEditorOpen && (
|
||||
<Dialog open={isBulkEditorOpen} onOpenChange={setIsBulkEditorOpen}>
|
||||
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
||||
<BulkCardEditor
|
||||
images={uploadedImages}
|
||||
defaultPackId={bulkUploadPackId}
|
||||
onComplete={() => {
|
||||
setIsBulkEditorOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('All cards saved successfully!')
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkEditorOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ export default function DashboardPage() {
|
|||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to Mnemo Cards Admin Panel
|
||||
Welcome to Sto k Odnomu Admin Panel
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -60,7 +60,7 @@ export default function DashboardPage() {
|
|||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to Mnemo Cards Admin Panel
|
||||
Welcome to Sto k Odnomu Admin Panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||
import { Label } from '@/components/ui/label'
|
||||
import type { CodeStatusResponse } from '@/types/models'
|
||||
|
||||
const TELEGRAM_BOT_USERNAME = 'mnemo_cards_bot'
|
||||
const TELEGRAM_BOT_USERNAME = 'sto_k_odnomu_bot'
|
||||
const TELEGRAM_BOT_DEEP_LINK_BASE = `https://t.me/${TELEGRAM_BOT_USERNAME}`
|
||||
|
||||
function telegramBotDeepLink(code: string): string {
|
||||
|
|
@ -299,7 +299,7 @@ export default function LoginPage() {
|
|||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Mnemo Cards Admin
|
||||
Sto k Odnomu Admin
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{codeStatus
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||||
import { PackCardsManager } from '@/components/PackCardsManager'
|
||||
import { PackTestsManager } from '@/components/PackTestsManager'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
export default function PacksPage() {
|
||||
|
|
@ -69,15 +67,6 @@ export default function PacksPage() {
|
|||
cover: undefined as string | undefined,
|
||||
})
|
||||
|
||||
// Card management state
|
||||
const [currentCardIds, setCurrentCardIds] = useState<string[]>([])
|
||||
const [cardsToAdd, setCardsToAdd] = useState<string[]>([])
|
||||
const [cardsToRemove, setCardsToRemove] = useState<string[]>([])
|
||||
|
||||
// Test management state
|
||||
const [currentTestIds, setCurrentTestIds] = useState<string[]>([])
|
||||
const [testsToAdd, setTestsToAdd] = useState<string[]>([])
|
||||
const [testsToRemove, setTestsToRemove] = useState<string[]>([])
|
||||
|
||||
const limit = 20
|
||||
|
||||
|
|
@ -161,12 +150,6 @@ export default function PacksPage() {
|
|||
size: 0,
|
||||
cover: undefined,
|
||||
})
|
||||
setCurrentCardIds([])
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
setCurrentTestIds([])
|
||||
setTestsToAdd([])
|
||||
setTestsToRemove([])
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -189,17 +172,6 @@ export default function PacksPage() {
|
|||
size: fullPack.size || 0,
|
||||
cover: fullPack.cover,
|
||||
})
|
||||
// Initialize current card IDs from addCardIds (which contains all cards in pack)
|
||||
const cardIds = fullPack.addCardIds?.map((id) => id.toString()) || []
|
||||
setCurrentCardIds(cardIds)
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
|
||||
// Initialize current test IDs from addTestIds (which contains all tests in pack)
|
||||
const testIds = fullPack.addTestIds?.map((id) => id.toString()) || []
|
||||
setCurrentTestIds(testIds)
|
||||
setTestsToAdd([])
|
||||
setTestsToRemove([])
|
||||
|
||||
setIsDialogOpen(true)
|
||||
} catch (error) {
|
||||
|
|
@ -214,22 +186,6 @@ export default function PacksPage() {
|
|||
const closeDialog = () => {
|
||||
setIsDialogOpen(false)
|
||||
setSelectedPack(null)
|
||||
setCurrentCardIds([])
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
setCurrentTestIds([])
|
||||
setTestsToAdd([])
|
||||
setTestsToRemove([])
|
||||
}
|
||||
|
||||
const handleCardsChange = (addIds: string[], removeIds: string[]) => {
|
||||
setCardsToAdd(addIds)
|
||||
setCardsToRemove(removeIds)
|
||||
}
|
||||
|
||||
const handleTestsChange = (addIds: string[], removeIds: string[]) => {
|
||||
setTestsToAdd(addIds)
|
||||
setTestsToRemove(removeIds)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
|
|
@ -256,10 +212,6 @@ export default function PacksPage() {
|
|||
version: formData.version.trim() || undefined,
|
||||
size: formData.size > 0 ? formData.size : undefined,
|
||||
cover: formData.cover || undefined,
|
||||
addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
|
||||
removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
|
||||
addTestIds: testsToAdd.length > 0 ? testsToAdd : undefined,
|
||||
removeTestIds: testsToRemove.length > 0 ? testsToRemove : undefined,
|
||||
}
|
||||
|
||||
if (selectedPack) {
|
||||
|
|
@ -616,24 +568,6 @@ export default function PacksPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{selectedPack && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<PackCardsManager
|
||||
currentCardIds={currentCardIds}
|
||||
onCardsChange={handleCardsChange}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<PackTestsManager
|
||||
currentTestIds={currentTestIds}
|
||||
onTestsChange={handleTestsChange}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
|
|
|
|||
|
|
@ -1,639 +0,0 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { testsApi, isTestsApiError } from '@/api/tests'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { TestDto, PaginatedResponse } from '@/types/models'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { questionFromJson, questionToJson } from '@/types/questions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
|
||||
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
|
||||
import { TestPacksManager } from '@/components/TestPacksManager'
|
||||
|
||||
export default function TestsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTest, setSelectedTest] = useState<TestDto | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [testToDelete, setTestToDelete] = useState<TestDto | null>(null)
|
||||
const [isQuestionDialogOpen, setIsQuestionDialogOpen] = useState(false)
|
||||
const [editingQuestion, setEditingQuestion] = useState<{
|
||||
question: Question | null
|
||||
index: number
|
||||
} | null>(null)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
color: '',
|
||||
cover: undefined as string | undefined,
|
||||
version: '',
|
||||
time: '',
|
||||
timeSubtitle: '',
|
||||
questions: [] as Question[],
|
||||
})
|
||||
|
||||
// Pack-linking state (test ↔ packs)
|
||||
const [currentPackIds, setCurrentPackIds] = useState<string[]>([])
|
||||
const [packsToAdd, setPacksToAdd] = useState<string[]>([])
|
||||
const [packsToRemove, setPacksToRemove] = useState<string[]>([])
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch tests
|
||||
const { data, isLoading, error } = useQuery<PaginatedResponse<TestDto>>({
|
||||
queryKey: ['tests', page, search],
|
||||
queryFn: () => testsApi.getTests({ page, limit, search }),
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on client errors (4xx)
|
||||
if (isTestsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const upsertMutation = useMutation({
|
||||
mutationFn: (test: TestDto) => testsApi.upsertTest(test),
|
||||
})
|
||||
|
||||
const syncPacksMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
testId,
|
||||
addPackIds,
|
||||
removePackIds,
|
||||
}: {
|
||||
testId: string
|
||||
addPackIds: string[]
|
||||
removePackIds: string[]
|
||||
}) => {
|
||||
await Promise.all([
|
||||
...addPackIds.map((packId) => packsApi.linkTestToPack(packId, testId)),
|
||||
...removePackIds.map((packId) =>
|
||||
packsApi.unlinkTestFromPack(packId, testId),
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (testId: string) => testsApi.deleteTest(testId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
||||
toast.success('Test deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setTestToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'delete', 'test')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error deleting test:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setSelectedTest(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
color: '',
|
||||
cover: undefined,
|
||||
version: '',
|
||||
time: '',
|
||||
timeSubtitle: '',
|
||||
questions: [],
|
||||
})
|
||||
setCurrentPackIds([])
|
||||
setPacksToAdd([])
|
||||
setPacksToRemove([])
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = async (test: TestDto) => {
|
||||
try {
|
||||
// Load full test data if we only have preview
|
||||
const fullTest = test.id ? await testsApi.getTest(test.id) : test
|
||||
setSelectedTest(fullTest)
|
||||
|
||||
const packIds = fullTest.packs?.map((p) => String(p.id)) || []
|
||||
setCurrentPackIds(packIds)
|
||||
setPacksToAdd([])
|
||||
setPacksToRemove([])
|
||||
|
||||
// Преобразуем вопросы из JSON в Question объекты
|
||||
const rawQuestions = Array.isArray(fullTest.questions)
|
||||
? fullTest.questions
|
||||
: []
|
||||
|
||||
const questions: Question[] = rawQuestions.map((q: unknown) => {
|
||||
try {
|
||||
return questionFromJson(q)
|
||||
} catch (e) {
|
||||
console.error('Error parsing question:', e, q)
|
||||
return null
|
||||
}
|
||||
}).filter((q): q is Question => q !== null)
|
||||
|
||||
setFormData({
|
||||
name: fullTest.name || '',
|
||||
color: fullTest.color || '',
|
||||
cover: fullTest.cover,
|
||||
version: fullTest.version || '',
|
||||
time: fullTest.time || '',
|
||||
timeSubtitle: fullTest.timeSubtitle || '',
|
||||
questions,
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
} catch (error) {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'load', `test "${test.id}"`)
|
||||
toast.error(errorMessage)
|
||||
console.error('Error loading test details:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false)
|
||||
setSelectedTest(null)
|
||||
setCurrentPackIds([])
|
||||
setPacksToAdd([])
|
||||
setPacksToRemove([])
|
||||
}
|
||||
|
||||
const handlePacksChange = useCallback((addIds: string[], removeIds: string[]) => {
|
||||
setPacksToAdd(addIds)
|
||||
setPacksToRemove(removeIds)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Name is required')
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем вопросы в JSON формат для отправки на бэкенд
|
||||
const questionsJson = formData.questions.map((q) => questionToJson(q))
|
||||
|
||||
const testData: TestDto = {
|
||||
id: selectedTest?.id,
|
||||
name: formData.name.trim(),
|
||||
color: formData.color.trim() || undefined,
|
||||
cover: formData.cover || undefined,
|
||||
version: formData.version.trim() || undefined,
|
||||
time: formData.time.trim() || undefined,
|
||||
timeSubtitle: formData.timeSubtitle.trim() || undefined,
|
||||
questions: questionsJson,
|
||||
}
|
||||
|
||||
const isUpdate = Boolean(selectedTest?.id)
|
||||
try {
|
||||
const response = await upsertMutation.mutateAsync(testData)
|
||||
const savedTestId = response.test.id ?? testData.id
|
||||
|
||||
if (!savedTestId) {
|
||||
throw new Error('Test was saved but no ID was returned')
|
||||
}
|
||||
|
||||
if (packsToAdd.length > 0 || packsToRemove.length > 0) {
|
||||
await syncPacksMutation.mutateAsync({
|
||||
testId: savedTestId,
|
||||
addPackIds: packsToAdd,
|
||||
removePackIds: packsToRemove,
|
||||
})
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||||
|
||||
toast.success(isUpdate ? 'Test updated successfully' : 'Test created successfully')
|
||||
closeDialog()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, isUpdate ? 'update' : 'create', 'test')
|
||||
|
||||
toast.error(errorMessage)
|
||||
console.error('Error saving test:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (test: TestDto) => {
|
||||
if (!test.id) {
|
||||
toast.error('Test ID is required for deletion')
|
||||
return
|
||||
}
|
||||
setTestToDelete(test)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (testToDelete?.id) {
|
||||
deleteMutation.mutate(testToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
setPage(1) // Reset to first page when searching
|
||||
}
|
||||
|
||||
const isSaving = upsertMutation.isPending || syncPacksMutation.isPending
|
||||
|
||||
if (error) {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
|
||||
<p className="text-muted-foreground">Error loading tests</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-500 font-medium">Failed to load tests</p>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['tests'] })}
|
||||
className="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View, create, edit and delete game tests
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tests..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tests Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Tests ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>
|
||||
Manage game tests in the system
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading tests...</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Packs</TableHead>
|
||||
<TableHead>Questions</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.items.map((test) => (
|
||||
<TableRow key={test.id || Math.random()}>
|
||||
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
|
||||
<TableCell className="font-medium">{test.name}</TableCell>
|
||||
<TableCell>
|
||||
{test.packs && test.packs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{test.packs.map((pack) => (
|
||||
<span
|
||||
key={pack.id}
|
||||
className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{pack.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">No packs</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{typeof test.questions === 'number'
|
||||
? test.questions
|
||||
: test.questions?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell>{test.version || 'N/A'}</TableCell>
|
||||
<TableCell>{test.time || 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(test)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(test)}
|
||||
disabled={!test.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} tests
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-6xl max-h-[95vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedTest ? 'Edit Test' : 'Create New Test'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedTest ? 'Update the test information' : 'Add a new test to the system'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Test name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<ColorPaletteInput
|
||||
id="color"
|
||||
value={formData.color}
|
||||
onChange={(value) =>
|
||||
setFormData((prev) => ({ ...prev, color: value }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
placeholder="#FF0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time">Time</Label>
|
||||
<Input
|
||||
id="time"
|
||||
value={formData.time}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, time: e.target.value }))}
|
||||
placeholder="e.g. 5 min"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeSubtitle">Time Subtitle</Label>
|
||||
<Input
|
||||
id="timeSubtitle"
|
||||
value={formData.timeSubtitle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, timeSubtitle: e.target.value }))}
|
||||
placeholder="e.g. per question"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Cover Image"
|
||||
value={formData.cover}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
|
||||
disabled={isSaving}
|
||||
uploadType="test-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TestPacksManager
|
||||
currentPackIds={currentPackIds}
|
||||
onPacksChange={handlePacksChange}
|
||||
onSelectedPackColorChange={(color) => {
|
||||
const trimmed = color.trim()
|
||||
if (!trimmed) return
|
||||
setFormData((prev) => ({ ...prev, color: trimmed }))
|
||||
}}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pack links are applied on Save.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TestQuestionsManager
|
||||
questions={formData.questions}
|
||||
onChange={(questions) =>
|
||||
setFormData((prev) => ({ ...prev, questions }))
|
||||
}
|
||||
onEdit={(question, index) => {
|
||||
setEditingQuestion({ question, index })
|
||||
setIsQuestionDialogOpen(true)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingQuestion({ question: null, index: -1 })
|
||||
setIsQuestionDialogOpen(true)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Question
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : (selectedTest ? 'Update' : 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Test</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the test "{testToDelete?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Question Editor Dialog */}
|
||||
<QuestionEditorDialog
|
||||
open={isQuestionDialogOpen}
|
||||
question={editingQuestion?.question || null}
|
||||
onSave={(question) => {
|
||||
if (editingQuestion) {
|
||||
const newQuestions = [...formData.questions]
|
||||
if (editingQuestion.index >= 0) {
|
||||
// Редактирование существующего вопроса
|
||||
newQuestions[editingQuestion.index] = question
|
||||
} else {
|
||||
// Добавление нового вопроса
|
||||
newQuestions.push(question)
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, questions: newQuestions }))
|
||||
}
|
||||
setIsQuestionDialogOpen(false)
|
||||
setEditingQuestion(null)
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuestionDialogOpen(false)
|
||||
setEditingQuestion(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +1,5 @@
|
|||
// Types based on backend DTOs
|
||||
|
||||
export interface GameCardDto {
|
||||
id: string | null
|
||||
packId?: string
|
||||
image?: string // Object ID in MinIO (for admin)
|
||||
imageUrl?: string // Presigned URL (for display)
|
||||
mnemo?: string
|
||||
original?: string
|
||||
translation?: string
|
||||
transcription?: string
|
||||
transcriptionMnemo?: string
|
||||
imageBack?: string // Object ID in MinIO (for admin)
|
||||
imageBackUrl?: string // Presigned URL (for display)
|
||||
back?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface EditCardPackDto {
|
||||
id?: string
|
||||
title?: string
|
||||
|
|
@ -31,11 +14,6 @@ export interface EditCardPackDto {
|
|||
description?: string
|
||||
enabled?: boolean
|
||||
version?: string
|
||||
addCardIds?: string[]
|
||||
addTestIds?: string[]
|
||||
removeCardIds?: string[]
|
||||
removeTestIds?: string[]
|
||||
previewCards?: string[]
|
||||
order?: number
|
||||
cardsOrder?: string[]
|
||||
}
|
||||
|
|
@ -172,27 +150,3 @@ export interface CodeStatusResponse {
|
|||
message?: string
|
||||
}
|
||||
|
||||
// Test types
|
||||
import type { Question } from './questions'
|
||||
|
||||
export type TestQuestion = Question
|
||||
|
||||
export interface TestPackInfo {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface TestDto {
|
||||
id?: string
|
||||
name: string
|
||||
color?: string
|
||||
cover?: string // Object ID in MinIO (for admin)
|
||||
coverUrl?: string // Presigned URL (for display)
|
||||
version?: string
|
||||
time?: string
|
||||
timeSubtitle?: string
|
||||
// Backend returns either a questions list (full test) or a questions count (list endpoint).
|
||||
questions: unknown[] | number
|
||||
statistics?: unknown
|
||||
packs?: TestPackInfo[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue