{/* Logo */}
-
Mnemo Admin
+
Sto k Odnomu Admin
-
Mnemo Cards Admin
+
Sto k Odnomu Admin
{/* Spacer */}
diff --git a/admin/src/pages/CardsPage.tsx b/admin/src/pages/CardsPage.tsx
deleted file mode 100644
index 2e516db..0000000
--- a/admin/src/pages/CardsPage.tsx
+++ /dev/null
@@ -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
('list')
- const [selectedCard, setSelectedCard] = useState(null)
- const [isDialogOpen, setIsDialogOpen] = useState(false)
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
- const [cardToDelete, setCardToDelete] = useState(null)
- const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false)
- const [isBulkEditorOpen, setIsBulkEditorOpen] = useState(false)
- const [uploadedImages, setUploadedImages] = useState>([])
- const [bulkUploadPackId, setBulkUploadPackId] = useState(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>({
- 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) || {}
-
- // 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 (
-
-
-
Cards Management
-
Error loading cards
-
-
-
-
-
Failed to load cards
-
{errorMessage}
-
-
-
-
-
- )
- }
-
- return (
-
-
-
-
Cards Management
-
- View, create, edit and delete game cards
-
-
-
-
-
-
-
-
- {/* Search */}
-
-
-
-
- handleSearch(e.target.value)}
- className="max-w-sm"
- />
-
-
-
-
- {/* Cards Table */}
-
-
-
-
- All Cards ({data?.total || 0})
-
- Manage game cards in the system
-
-
-
-
-
-
-
-
-
- {isLoading ? (
- Loading cards...
- ) : (
- <>
- {viewMode === 'list' ? (
- <>
- {Object.entries(groupedCards).map(([packId, cards]) => (
-
-
-
- {getPackName(packId)}
-
-
- {cards.length} {cards.length === 1 ? 'card' : 'cards'}
-
-
-
-
-
- ID
- Original
- Translation
- Mnemo
- Actions
-
-
-
- {cards.map((card) => (
-
- {card.id || '-'}
- {card.original}
- {card.translation}
- {card.mnemo}
-
-
-
-
-
-
-
- ))}
-
-
-
- ))}
- >
- ) : (
- <>
- {Object.entries(groupedCards).map(([packId, cards]) => (
-
-
-
- {getPackName(packId)}
-
-
- {cards.length} {cards.length === 1 ? 'card' : 'cards'}
-
-
-
- {cards.map((card) => (
-
openEditDialog(card)}
- >
-
- {getImageSrc(card) ? (
-
})
{
- const target = e.target as HTMLImageElement
- target.style.display = 'none'
- }}
- />
- ) : (
-
- No Image
-
- )}
-
-
-
-
-
-
-
-
-
- {card.original}
-
- {card.translation && (
-
- {card.translation}
-
- )}
-
-
- ))}
-
-
- ))}
- >
- )}
-
- {/* Pagination */}
- {data && data.totalPages > 1 && (
-
-
- Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} cards
-
-
-
-
- Page {page} of {data.totalPages}
-
-
-
-
- )}
- >
- )}
-
-
-
- {/* Create/Edit Dialog */}
-
-
- {/* Delete Confirmation Dialog */}
-
-
-
- Delete Card
-
- Are you sure you want to delete the card "{cardToDelete?.original}"? This action cannot be undone.
-
-
-
- Cancel
-
- {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
-
-
-
-
-
- {/* Bulk Upload Dialog */}
- {isBulkUploadOpen && !isBulkEditorOpen && (
-
- )}
-
- {/* Bulk Editor Dialog */}
- {isBulkEditorOpen && (
-
- )}
-
- )
-}
diff --git a/admin/src/pages/DashboardPage.tsx b/admin/src/pages/DashboardPage.tsx
index 6000beb..20c8e26 100644
--- a/admin/src/pages/DashboardPage.tsx
+++ b/admin/src/pages/DashboardPage.tsx
@@ -36,7 +36,7 @@ export default function DashboardPage() {
Dashboard
- Welcome to Mnemo Cards Admin Panel
+ Welcome to Sto k Odnomu Admin Panel
@@ -60,7 +60,7 @@ export default function DashboardPage() {
Dashboard
- Welcome to Mnemo Cards Admin Panel
+ Welcome to Sto k Odnomu Admin Panel
diff --git a/admin/src/pages/LoginPage.tsx b/admin/src/pages/LoginPage.tsx
index a93b29d..25a2020 100644
--- a/admin/src/pages/LoginPage.tsx
+++ b/admin/src/pages/LoginPage.tsx
@@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Label } from '@/components/ui/label'
import type { CodeStatusResponse } from '@/types/models'
-const TELEGRAM_BOT_USERNAME = 'mnemo_cards_bot'
+const TELEGRAM_BOT_USERNAME = 'sto_k_odnomu_bot'
const TELEGRAM_BOT_DEEP_LINK_BASE = `https://t.me/${TELEGRAM_BOT_USERNAME}`
function telegramBotDeepLink(code: string): string {
@@ -299,7 +299,7 @@ export default function LoginPage() {
- Mnemo Cards Admin
+ Sto k Odnomu Admin
{codeStatus
diff --git a/admin/src/pages/PacksPage.tsx b/admin/src/pages/PacksPage.tsx
index 0ecd3db..039355c 100644
--- a/admin/src/pages/PacksPage.tsx
+++ b/admin/src/pages/PacksPage.tsx
@@ -38,8 +38,6 @@ import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { ImageUpload } from '@/components/ui/image-upload'
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
-import { PackCardsManager } from '@/components/PackCardsManager'
-import { PackTestsManager } from '@/components/PackTestsManager'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
export default function PacksPage() {
@@ -69,15 +67,6 @@ export default function PacksPage() {
cover: undefined as string | undefined,
})
- // Card management state
- const [currentCardIds, setCurrentCardIds] = useState([])
- const [cardsToAdd, setCardsToAdd] = useState([])
- const [cardsToRemove, setCardsToRemove] = useState([])
-
- // Test management state
- const [currentTestIds, setCurrentTestIds] = useState([])
- const [testsToAdd, setTestsToAdd] = useState([])
- const [testsToRemove, setTestsToRemove] = useState([])
const limit = 20
@@ -161,12 +150,6 @@ export default function PacksPage() {
size: 0,
cover: undefined,
})
- setCurrentCardIds([])
- setCardsToAdd([])
- setCardsToRemove([])
- setCurrentTestIds([])
- setTestsToAdd([])
- setTestsToRemove([])
setIsDialogOpen(true)
}
@@ -189,17 +172,6 @@ export default function PacksPage() {
size: fullPack.size || 0,
cover: fullPack.cover,
})
- // Initialize current card IDs from addCardIds (which contains all cards in pack)
- const cardIds = fullPack.addCardIds?.map((id) => id.toString()) || []
- setCurrentCardIds(cardIds)
- setCardsToAdd([])
- setCardsToRemove([])
-
- // Initialize current test IDs from addTestIds (which contains all tests in pack)
- const testIds = fullPack.addTestIds?.map((id) => id.toString()) || []
- setCurrentTestIds(testIds)
- setTestsToAdd([])
- setTestsToRemove([])
setIsDialogOpen(true)
} catch (error) {
@@ -214,22 +186,6 @@ export default function PacksPage() {
const closeDialog = () => {
setIsDialogOpen(false)
setSelectedPack(null)
- setCurrentCardIds([])
- setCardsToAdd([])
- setCardsToRemove([])
- setCurrentTestIds([])
- setTestsToAdd([])
- setTestsToRemove([])
- }
-
- const handleCardsChange = (addIds: string[], removeIds: string[]) => {
- setCardsToAdd(addIds)
- setCardsToRemove(removeIds)
- }
-
- const handleTestsChange = (addIds: string[], removeIds: string[]) => {
- setTestsToAdd(addIds)
- setTestsToRemove(removeIds)
}
const handleSubmit = (e: React.FormEvent) => {
@@ -256,10 +212,6 @@ export default function PacksPage() {
version: formData.version.trim() || undefined,
size: formData.size > 0 ? formData.size : undefined,
cover: formData.cover || undefined,
- addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
- removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
- addTestIds: testsToAdd.length > 0 ? testsToAdd : undefined,
- removeTestIds: testsToRemove.length > 0 ? testsToRemove : undefined,
}
if (selectedPack) {
@@ -616,24 +568,6 @@ export default function PacksPage() {
/>
- {selectedPack && (
- <>
-
-
- >
- )}