admin cleanuo

This commit is contained in:
Dmitry 2026-01-07 00:25:06 +03:00
parent afe5879b62
commit 91b66ed81d
20 changed files with 8 additions and 3538 deletions

View file

@ -2,10 +2,8 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import LoginPage from '@/pages/LoginPage' import LoginPage from '@/pages/LoginPage'
import DashboardPage from '@/pages/DashboardPage' import DashboardPage from '@/pages/DashboardPage'
import CardsPage from '@/pages/CardsPage'
import PacksPage from '@/pages/PacksPage' import PacksPage from '@/pages/PacksPage'
import UsersPage from '@/pages/UsersPage' import UsersPage from '@/pages/UsersPage'
import TestsPage from '@/pages/TestsPage'
import Layout from '@/components/layout/Layout' import Layout from '@/components/layout/Layout'
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider' import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
@ -27,10 +25,8 @@ function App() {
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/cards" element={<CardsPage />} />
<Route path="/packs" element={<PacksPage />} /> <Route path="/packs" element={<PacksPage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/tests" element={<TestsPage />} />
</Routes> </Routes>
</Layout> </Layout>
) : ( ) : (

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ describe('API Clients', () => {
describe('apiClient', () => { describe('apiClient', () => {
it('should be defined and have correct base URL', () => { it('should be defined and have correct base URL', () => {
expect(apiClient).toBeDefined() 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', () => { it('should have interceptors configured', () => {
@ -24,7 +24,7 @@ describe('API Clients', () => {
describe('adminApiClient', () => { describe('adminApiClient', () => {
it('should always use production API URL', () => { it('should always use production API URL', () => {
expect(adminApiClient).toBeDefined() 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', () => { it('should be a separate instance from apiClient', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,9 +20,7 @@ interface LayoutProps {
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard }, { name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Cards', href: '/cards', icon: FileText },
{ name: 'Packs', href: '/packs', icon: Package }, { name: 'Packs', href: '/packs', icon: Package },
{ name: 'Tests', href: '/tests', icon: ClipboardList },
{ name: 'Users', href: '/users', icon: Users }, { name: 'Users', href: '/users', icon: Users },
] ]
@ -56,7 +54,7 @@ export default function Layout({ children }: LayoutProps) {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Logo */} {/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200"> <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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -116,7 +114,7 @@ export default function Layout({ children }: LayoutProps) {
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </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 className="w-10" /> {/* Spacer */}
</div> </div>
</header> </header>

View file

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

View file

@ -36,7 +36,7 @@ export default function DashboardPage() {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1> <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Welcome to Mnemo Cards Admin Panel Welcome to Sto k Odnomu Admin Panel
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@ -60,7 +60,7 @@ export default function DashboardPage() {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1> <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Welcome to Mnemo Cards Admin Panel Welcome to Sto k Odnomu Admin Panel
</p> </p>
</div> </div>

View file

@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import type { CodeStatusResponse } from '@/types/models' 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}` const TELEGRAM_BOT_DEEP_LINK_BASE = `https://t.me/${TELEGRAM_BOT_USERNAME}`
function telegramBotDeepLink(code: string): string { function telegramBotDeepLink(code: string): string {
@ -299,7 +299,7 @@ export default function LoginPage() {
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center"> <CardTitle className="text-2xl font-bold text-center">
Mnemo Cards Admin Sto k Odnomu Admin
</CardTitle> </CardTitle>
<CardDescription className="text-center"> <CardDescription className="text-center">
{codeStatus {codeStatus

View file

@ -38,8 +38,6 @@ import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { ImageUpload } from '@/components/ui/image-upload' import { ImageUpload } from '@/components/ui/image-upload'
import { ColorPaletteInput } from '@/components/ui/color-palette-input' 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' import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
export default function PacksPage() { export default function PacksPage() {
@ -69,15 +67,6 @@ export default function PacksPage() {
cover: undefined as string | undefined, 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 const limit = 20
@ -161,12 +150,6 @@ export default function PacksPage() {
size: 0, size: 0,
cover: undefined, cover: undefined,
}) })
setCurrentCardIds([])
setCardsToAdd([])
setCardsToRemove([])
setCurrentTestIds([])
setTestsToAdd([])
setTestsToRemove([])
setIsDialogOpen(true) setIsDialogOpen(true)
} }
@ -189,17 +172,6 @@ export default function PacksPage() {
size: fullPack.size || 0, size: fullPack.size || 0,
cover: fullPack.cover, 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) setIsDialogOpen(true)
} catch (error) { } catch (error) {
@ -214,22 +186,6 @@ export default function PacksPage() {
const closeDialog = () => { const closeDialog = () => {
setIsDialogOpen(false) setIsDialogOpen(false)
setSelectedPack(null) 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) => { const handleSubmit = (e: React.FormEvent) => {
@ -256,10 +212,6 @@ export default function PacksPage() {
version: formData.version.trim() || undefined, version: formData.version.trim() || undefined,
size: formData.size > 0 ? formData.size : undefined, size: formData.size > 0 ? formData.size : undefined,
cover: formData.cover || 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) { if (selectedPack) {
@ -616,24 +568,6 @@ export default function PacksPage() {
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}> <Button type="button" variant="outline" onClick={closeDialog}>

View file

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

View file

@ -1,22 +1,5 @@
// Types based on backend DTOs // 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 { export interface EditCardPackDto {
id?: string id?: string
title?: string title?: string
@ -31,11 +14,6 @@ export interface EditCardPackDto {
description?: string description?: string
enabled?: boolean enabled?: boolean
version?: string version?: string
addCardIds?: string[]
addTestIds?: string[]
removeCardIds?: string[]
removeTestIds?: string[]
previewCards?: string[]
order?: number order?: number
cardsOrder?: string[] cardsOrder?: string[]
} }
@ -172,27 +150,3 @@ export interface CodeStatusResponse {
message?: string 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[]
}