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