This commit is contained in:
Dmitry 2026-01-07 16:28:48 +03:00
parent a61008f71a
commit 841a6bdc74
7 changed files with 156 additions and 1325 deletions

View file

@ -1,109 +0,0 @@
import { adminApiClient } from './client'
export interface UploadResponse {
objectId: string
url: string // Presigned URL for preview
}
export interface PresignedUrlResponse {
url: string
expiresAt: string
}
export const mediaApi = {
/**
* Upload card image to MinIO
* @param file Image file to upload
* @returns Object ID and presigned URL
*/
async uploadCardImage(file: File): Promise<UploadResponse> {
const formData = new FormData()
formData.append('file', file)
const response = await adminApiClient.post<UploadResponse>(
'/api/v2/media/upload/card-image',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data
},
/**
* Upload test image to MinIO
* @param file Image file to upload
* @returns Object ID and presigned URL
*/
async uploadTestImage(file: File): Promise<UploadResponse> {
const formData = new FormData()
formData.append('file', file)
const response = await adminApiClient.post<UploadResponse>(
'/api/v2/media/upload/test-image',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data
},
/**
* Upload voice audio file to MinIO
* @param file Audio file to upload
* @returns Object ID and presigned URL
*/
async uploadVoice(file: File): Promise<UploadResponse> {
const formData = new FormData()
formData.append('file', file)
const response = await adminApiClient.post<UploadResponse>(
'/api/v2/media/upload/voice',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data
},
/**
* Get presigned URL for an object
* @param bucket Bucket name (card-images, test-images, voice-audio)
* @param objectId Object ID (UUID)
* @param expirySeconds Optional expiry time in seconds
* @returns Presigned URL and expiration time
*/
async getPresignedUrl(
bucket: string,
objectId: string,
expirySeconds?: number
): Promise<PresignedUrlResponse> {
const params = expirySeconds ? { expirySeconds: expirySeconds.toString() } : {}
const response = await adminApiClient.get<PresignedUrlResponse>(
`/api/v2/media/${bucket}/${objectId}/url`,
{ params }
)
return response.data
},
/**
* Delete a file from MinIO
* @param bucket Bucket name
* @param objectId Object ID to delete
*/
async deleteFile(bucket: string, objectId: string): Promise<void> {
await adminApiClient.delete(`/api/v2/media/${bucket}/${objectId}`)
},
}

View file

@ -48,10 +48,23 @@ export const packsApi = {
page: params?.page || 1,
limit: params?.limit || 20,
search: params?.search,
showDisabled: params?.showDisabled,
isPublic: params?.showDisabled === false ? true : undefined,
},
})
return response.data
// Transform backend response to match frontend PaginatedResponse
const backendData = response.data
return {
items: backendData.packs.map((pack: any) => ({
id: pack.id,
title: pack.name,
cards: pack.questionCount,
enabled: pack.isPublic,
})),
total: backendData.total,
page: backendData.page,
limit: backendData.limit,
totalPages: backendData.totalPages,
}
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
@ -83,7 +96,16 @@ export const packsApi = {
getPack: async (packId: string): Promise<EditCardPackDto> => {
try {
const response = await adminApiClient.get(`/api/admin/packs/${packId}`)
return response.data
const pack = response.data
// Transform backend response to match EditCardPackDto
return {
id: pack.id,
name: pack.name,
description: pack.description,
category: pack.category,
isPublic: pack.isPublic,
questions: pack.questions,
}
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string }>
@ -110,8 +132,22 @@ export const packsApi = {
// Create or update pack
upsertPack: async (pack: EditCardPackDto): Promise<{ success: boolean; pack: EditCardPackDto }> => {
try {
const response = await adminApiClient.post('/api/admin/packs', pack)
return response.data
const isUpdate = pack.id && pack.id.length > 0
const url = isUpdate ? `/api/admin/packs/${pack.id}` : '/api/admin/packs'
const method = isUpdate ? 'patch' : 'post'
const response = await adminApiClient[method](url, {
name: pack.name,
description: pack.description,
category: pack.category,
isPublic: pack.isPublic,
questions: pack.questions,
})
return {
success: true,
pack: response.data,
}
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
const isUpdate = pack.id && pack.id.length > 0

View file

@ -1,372 +0,0 @@
import { useState, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Button } from './ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
import { Label } from './ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
import { Upload, X, Check, Loader2 } from 'lucide-react'
import { packsApi } from '@/api/packs'
import { mediaApi } from '@/api/media'
interface UploadedImage {
id: string
file: File
preview: string
objectId: string // Object ID in MinIO (UUID)
presignedUrl?: string // Presigned URL for preview
isUploading?: boolean
uploadError?: string
}
interface BulkCardUploadProps {
onImagesUploaded: (images: UploadedImage[], packId?: string) => void
onClose: () => void
}
export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedPackId, setSelectedPackId] = useState<string>('')
const [isUploading, setIsUploading] = useState(false)
// Fetch packs for pack selection
const { data: packsData } = useQuery({
queryKey: ['packs', 1, 100, ''],
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }),
})
const handleFileSelect = async (files: FileList) => {
const filesArray = Array.from(files)
// Filter valid files
const validFiles = filesArray.filter((file) => {
if (!file.type.startsWith('image/')) {
return false
}
const fileSizeMB = file.size / (1024 * 1024)
if (fileSizeMB > 10) { // Updated to match backend limit
return false
}
return true
})
if (validFiles.length === 0) {
alert('No valid image files selected. Please select PNG, JPG, WEBP, or GIF files up to 10MB each.')
return
}
setIsUploading(true)
// Create placeholder entries with loading state
const placeholders: UploadedImage[] = validFiles.map((file, i) => ({
id: `${Date.now()}-${i}`,
file,
preview: URL.createObjectURL(file), // Temporary preview
objectId: '', // Will be set after upload
isUploading: true,
}))
setUploadedImages((prev) => [...prev, ...placeholders])
// Upload files in parallel (limit to 5 concurrent uploads)
const uploadPromises = validFiles.map(async (file, index) => {
const placeholderId = placeholders[index].id
try {
const response = await mediaApi.uploadCardImage(file)
// Update the placeholder with objectId and presigned URL
setUploadedImages((prev) =>
prev.map((img) =>
img.id === placeholderId
? {
...img,
objectId: response.objectId,
presignedUrl: response.url,
isUploading: false,
}
: img
)
)
} catch (error) {
console.error('Error uploading file:', file.name, error)
// Mark as error
setUploadedImages((prev) =>
prev.map((img) =>
img.id === placeholderId
? {
...img,
isUploading: false,
uploadError: 'Upload failed',
}
: img
)
)
}
})
// Process in batches of 5
const batchSize = 5
for (let i = 0; i < uploadPromises.length; i += batchSize) {
const batch = uploadPromises.slice(i, i + batchSize)
await Promise.all(batch)
}
setIsUploading(false)
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
handleFileSelect(files)
}
// Reset input value to allow selecting the same files again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer.files
if (files && files.length > 0) {
handleFileSelect(files)
}
}
const handleRemove = (id: string) => {
setUploadedImages((prev) => {
const image = prev.find((img) => img.id === id)
if (image) {
URL.revokeObjectURL(image.preview)
}
return prev.filter((img) => img.id !== id)
})
}
const handleClearAll = () => {
uploadedImages.forEach((img) => URL.revokeObjectURL(img.preview))
setUploadedImages([])
}
const handleContinue = () => {
// Filter out images that failed to upload or are still uploading
const validImages = uploadedImages.filter(
(img) => img.objectId && !img.isUploading && !img.uploadError
)
if (validImages.length === 0) {
alert('Please wait for all images to finish uploading, or remove failed uploads.')
return
}
if (validImages.length < uploadedImages.length) {
const failedCount = uploadedImages.length - validImages.length
if (
!confirm(
`${failedCount} image(s) failed to upload or are still uploading. Continue with ${validImages.length} successfully uploaded images?`
)
) {
return
}
}
onImagesUploaded(validImages, selectedPackId || undefined)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Bulk Card Upload</h2>
<p className="text-sm text-muted-foreground">
Upload multiple images, then fill in card details one by one
</p>
</div>
<Button variant="outline" onClick={onClose}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Upload Images</CardTitle>
<CardDescription>
Select multiple image files or drag and drop them here
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4 mb-4">
<div className="space-y-2">
<Label htmlFor="packId">Pack (optional)</Label>
<Select
value={selectedPackId || undefined}
onValueChange={(value: string) => {
const packId = value === '__none__' ? '' : value
setSelectedPackId(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>
<p className="text-xs text-muted-foreground">
Selected pack will be applied to all uploaded cards
</p>
</div>
</div>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleInputChange}
className="hidden"
/>
<div className="flex flex-col items-center justify-center space-y-4">
{isUploading ? (
<Loader2 className="h-12 w-12 animate-spin text-muted-foreground" />
) : (
<Upload className="h-12 w-12 text-muted-foreground" />
)}
<div className="text-lg">
{isUploading ? (
<span className="text-muted-foreground">Uploading images...</span>
) : (
<>
<span className="text-primary font-medium">Click to upload</span> or drag and drop
</>
)}
</div>
<p className="text-sm text-muted-foreground">
PNG, JPG, WEBP, GIF up to 10MB each. Multiple files supported.
</p>
</div>
</div>
</CardContent>
</Card>
{uploadedImages.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Uploaded Images ({uploadedImages.length})</CardTitle>
<CardDescription>
Review uploaded images and continue to fill in card details
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={handleClearAll}>
<X className="h-4 w-4 mr-2" />
Clear All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-h-[500px] overflow-y-auto">
{uploadedImages.map((image) => (
<div key={image.id} className="relative group">
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
{image.isUploading ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : image.uploadError ? (
<div className="w-full h-full flex flex-col items-center justify-center p-2 text-center">
<X className="h-6 w-6 text-destructive mb-1" />
<p className="text-xs text-destructive">Upload failed</p>
</div>
) : (
<>
<img
src={image.presignedUrl || image.preview}
alt={image.file.name}
className="w-full h-full object-cover"
onError={() => {
// Fallback to object URL if presigned URL fails
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
<Button
variant="destructive"
size="sm"
className="opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
handleRemove(image.id)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
</>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 truncate" title={image.file.name}>
{image.file.name}
{image.isUploading && ' (uploading...)'}
{image.uploadError && ' (failed)'}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{uploadedImages.length > 0 && (
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleContinue} disabled={isUploading}>
<Check className="h-4 w-4 mr-2" />
Continue to Fill Details (
{uploadedImages.filter((img) => img.objectId && !img.isUploading && !img.uploadError).length}{' '}
cards)
</Button>
</div>
)}
</div>
)
}

View file

@ -1,335 +0,0 @@
import { useRef, useState, useEffect } from 'react'
import { Button } from './button'
import { Label } from './label'
import { Input } from './input'
import { X, Music, Play, Pause, Loader2 } from 'lucide-react'
import { mediaApi } from '@/api/media'
interface AudioUploadProps {
label?: string
value?: string // Object ID (UUID) or legacy base64
onChange: (value: string | undefined) => void
language?: string
onLanguageChange?: (language: string) => void
accept?: string
maxSizeMB?: number
disabled?: boolean
}
export function AudioUpload({
label,
value,
onChange,
language = 'en',
onLanguageChange,
accept = 'audio/*',
maxSizeMB = 20, // Updated to match backend limit
disabled = false,
}: AudioUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [presignedUrl, setPresignedUrl] = useState<string | null>(null)
// Load presigned URL for existing objectId
useEffect(() => {
if (value && !presignedUrl) {
// Check if value is a UUID (objectId) or legacy format
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
if (isUuid) {
// It's an objectId, fetch presigned URL
mediaApi
.getPresignedUrl('voice-audio', value)
.then((response) => {
setPresignedUrl(response.url)
})
.catch((error) => {
console.error('Failed to load presigned URL:', error)
// Keep presignedUrl as null
})
} else {
// Legacy base64 format
setPresignedUrl(`data:audio/mpeg;base64,${value}`)
}
} else if (!value) {
setPresignedUrl(null)
}
}, [value, presignedUrl])
const handleFileSelect = async (file: File) => {
// Validate file size
const fileSizeMB = file.size / (1024 * 1024)
if (fileSizeMB > maxSizeMB) {
alert(`File size must be less than ${maxSizeMB}MB`)
return
}
// Validate file type
if (!file.type.startsWith('audio/')) {
alert('Please select an audio file')
return
}
try {
setIsUploading(true)
// Upload to MinIO via Media API
const response = await mediaApi.uploadVoice(file)
// Save objectId (not presigned URL!)
onChange(response.objectId)
// Use presigned URL for playback
setPresignedUrl(response.url)
} catch (error) {
console.error('Error uploading audio:', error)
alert('Failed to upload audio. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleFileSelect(file)
}
// Reset input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (disabled) return
const file = e.dataTransfer.files?.[0]
if (file) {
handleFileSelect(file)
}
}
const handleRemove = () => {
onChange(undefined)
setPresignedUrl(null)
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setIsPlaying(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = () => {
if (!disabled && !isUploading) {
fileInputRef.current?.click()
}
}
// Cleanup audio on unmount or value change
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setIsPlaying(false)
}
}, [value, presignedUrl])
const handlePlayPause = () => {
if (!presignedUrl) return
if (!audioRef.current) {
try {
const audio = new Audio(presignedUrl)
audioRef.current = audio
audio.onended = () => {
setIsPlaying(false)
audioRef.current = null
}
audio.onerror = (e) => {
console.error('Audio playback error:', e)
alert('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)
alert('Failed to play audio. Please check your browser audio settings.')
setIsPlaying(false)
audioRef.current = null
})
} catch (error) {
console.error('Error creating audio element:', error)
alert('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)
alert('Failed to resume audio playback')
})
setIsPlaying(true)
}
}
}
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
{value || isUploading ? (
<div className="relative">
<div className={`flex items-center justify-between p-4 border rounded-lg transition-colors ${
isPlaying ? 'bg-primary/5 border-primary/20' : 'bg-muted'
}`}>
{isUploading ? (
<div className="flex items-center space-x-3 flex-1">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Uploading audio...</span>
</div>
) : (
<>
<div className="flex items-center space-x-3 flex-1 min-w-0">
<Button
type="button"
variant={isPlaying ? "default" : "outline"}
size="sm"
onClick={handlePlayPause}
disabled={disabled || !presignedUrl}
className="flex items-center space-x-1 flex-shrink-0"
>
{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>
<Music className={`h-5 w-5 flex-shrink-0 ${
isPlaying ? 'text-primary' : 'text-muted-foreground'
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Audio file loaded</span>
{isPlaying && (
<span className="ml-2 text-xs text-primary animate-pulse">Playing...</span>
)}
{!presignedUrl && (
<span className="ml-2 text-xs text-muted-foreground">(Loading preview...)</span>
)}
</div>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
disabled={disabled}
className="flex-shrink-0"
>
<X className="h-4 w-4" />
</Button>
</>
)}
</div>
{onLanguageChange && (
<div className="mt-2">
<Label htmlFor="language" className="text-xs">Language</Label>
<Input
id="language"
value={language}
onChange={(e) => onLanguageChange(e.target.value)}
placeholder="en"
className="mt-1"
disabled={disabled}
/>
</div>
)}
<p className="text-sm text-muted-foreground mt-1">
{isUploading ? 'Uploading...' : 'Click to change audio file'}
</p>
</div>
) : (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
} ${disabled || isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="hidden"
disabled={disabled || isUploading}
/>
<div className="flex flex-col items-center justify-center space-y-2">
{isUploading ? (
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
) : (
<Music className="h-10 w-10 text-muted-foreground" />
)}
<div className="text-sm">
{isUploading ? (
<span className="text-muted-foreground">Uploading...</span>
) : (
<>
<span className="text-primary font-medium">Click to upload</span> or drag and drop
</>
)}
</div>
<p className="text-xs text-muted-foreground">
MP3, WAV, OGG, FLAC up to {maxSizeMB}MB
</p>
</div>
</div>
)}
</div>
)
}

View file

@ -1,242 +0,0 @@
import { useRef, useState, useEffect } from 'react'
import { Button } from './button'
import { Label } from './label'
import { X, Image as ImageIcon, Loader2 } from 'lucide-react'
import { mediaApi } from '@/api/media'
interface ImageUploadProps {
label?: string
value?: string // Object ID (UUID) or legacy base64/URL
onChange: (value: string | undefined) => void
accept?: string
maxSizeMB?: number
disabled?: boolean
uploadType?: 'card-image' | 'test-image' // Type of upload endpoint
}
export function ImageUpload({
label,
value,
onChange,
accept = 'image/*',
maxSizeMB = 10, // Updated to match backend limit
disabled = false,
uploadType = 'card-image',
}: ImageUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
// Load presigned URL for existing objectId
useEffect(() => {
if (value && !previewUrl) {
// Check if value is a UUID (objectId) or legacy format
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
if (isUuid) {
// It's an objectId, fetch presigned URL
const bucket = uploadType === 'card-image' ? 'card-images' : 'test-images'
mediaApi
.getPresignedUrl(bucket, value)
.then((response) => {
setPreviewUrl(response.url)
})
.catch((error) => {
console.error('Failed to load preview URL:', error)
// Keep previewUrl as null, will show placeholder
})
} else if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('/api/')
) {
// Legacy URL format
setPreviewUrl(value)
} else {
// Legacy base64 format
setPreviewUrl(`data:image/png;base64,${value}`)
}
} else if (!value) {
setPreviewUrl(null)
}
}, [value, uploadType, previewUrl])
const handleFileSelect = async (file: File) => {
// Validate file size
const fileSizeMB = file.size / (1024 * 1024)
if (fileSizeMB > maxSizeMB) {
alert(`File size must be less than ${maxSizeMB}MB`)
return
}
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file')
return
}
try {
setIsUploading(true)
// Upload to MinIO via Media API
const uploadEndpoint = uploadType === 'card-image'
? mediaApi.uploadCardImage(file)
: mediaApi.uploadTestImage(file)
const response = await uploadEndpoint
// Save objectId (not presigned URL!)
onChange(response.objectId)
// Use presigned URL for preview
setPreviewUrl(response.url)
} catch (error) {
console.error('Error uploading image:', error)
alert('Failed to upload image. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleFileSelect(file)
}
// Reset input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (disabled) return
const file = e.dataTransfer.files?.[0]
if (file) {
handleFileSelect(file)
}
}
const handleRemove = () => {
onChange(undefined)
setPreviewUrl(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = () => {
if (!disabled && !isUploading) {
fileInputRef.current?.click()
}
}
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
{value || previewUrl ? (
<div className="relative">
<div className="relative w-full border rounded-lg overflow-hidden bg-muted">
{isUploading ? (
<div className="w-full h-48 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : previewUrl ? (
<img
src={previewUrl}
alt="Preview"
className="w-full h-48 object-contain cursor-pointer"
onClick={handleClick}
onError={() => {
// If presigned URL fails, clear preview
setPreviewUrl(null)
}}
/>
) : (
<div className="w-full h-48 flex items-center justify-center text-muted-foreground">
Image loaded (preview unavailable)
</div>
)}
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={(e) => {
e.stopPropagation()
handleRemove()
}}
disabled={disabled || isUploading}
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground mt-1">
{isUploading ? 'Uploading...' : 'Click image to change'}
</p>
</div>
) : (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
} ${disabled || isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="hidden"
disabled={disabled || isUploading}
/>
<div className="flex flex-col items-center justify-center space-y-2">
{isUploading ? (
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-10 w-10 text-muted-foreground" />
)}
<div className="text-sm">
{isUploading ? (
<span className="text-muted-foreground">Uploading...</span>
) : (
<>
<span className="text-primary font-medium">Click to upload</span> or drag and drop
</>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG, WEBP, GIF up to {maxSizeMB}MB
</p>
</div>
</div>
)}
</div>
)
}

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { usersApi } from '@/api/users'
import type { UserDto, PaginatedResponse, PaymentDto } from '@/types/models'
import type { UserDto, PaginatedResponse } from '@/types/models'
import type { AxiosError } from 'axios'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -34,10 +34,9 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { packsApi } from '@/api/packs'
import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
export default function UsersPage() {
const queryClient = useQueryClient()
@ -46,18 +45,13 @@ export default function UsersPage() {
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isPurchasesDialogOpen, setIsPurchasesDialogOpen] = useState(false)
const [userToDelete, setUserToDelete] = useState<UserDto | null>(null)
const [userPurchases, setUserPurchases] = useState<PaymentDto[]>([])
// Form state
// Form state matching new UserDto
const [formData, setFormData] = useState({
name: '',
email: '',
admin: false,
subscription: false,
packs: [] as string[],
subscriptionFeatures: [] as string[],
role: 'USER' as 'USER' | 'ADMIN',
})
const limit = 20
@ -68,15 +62,10 @@ export default function UsersPage() {
queryFn: () => usersApi.getUsers({ page, limit }),
})
// Fetch packs for selection
const { data: packsData } = useQuery({
queryKey: ['packs', 1, 100, ''],
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '', showDisabled: true }),
})
// Mutations
const updateMutation = useMutation({
mutationFn: (user: UserDto) => usersApi.upsertUser(user),
mutationFn: ({ userId, userData }: { userId: string; userData: Partial<UserDto> }) =>
usersApi.updateUser(userId, userData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User updated successfully')
@ -107,10 +96,7 @@ export default function UsersPage() {
setFormData({
name: user.name || '',
email: user.email || '',
admin: user.admin,
subscription: user.subscription || false,
packs: [...user.packs],
subscriptionFeatures: [...user.subscriptionFeatures],
role: user.role,
})
setIsEditDialogOpen(true)
}
@ -120,25 +106,6 @@ export default function UsersPage() {
setSelectedUser(null)
}
const openPurchasesDialog = async (user: UserDto) => {
if (!user.id) return
try {
const purchases = await usersApi.getUserPurchases(user.id.toString())
setUserPurchases(purchases)
setSelectedUser(user)
setIsPurchasesDialogOpen(true)
} catch {
toast.error('Failed to load user purchases')
}
}
const closePurchasesDialog = () => {
setIsPurchasesDialogOpen(false)
setSelectedUser(null)
setUserPurchases([])
}
const handleDelete = (user: UserDto) => {
setUserToDelete(user)
setIsDeleteDialogOpen(true)
@ -146,7 +113,7 @@ export default function UsersPage() {
const confirmDelete = () => {
if (userToDelete?.id) {
deleteMutation.mutate(userToDelete.id.toString())
deleteMutation.mutate(userToDelete.id)
}
}
@ -158,18 +125,13 @@ export default function UsersPage() {
return
}
const userData: UserDto = {
id: selectedUser.id,
const userData: Partial<UserDto> = {
name: formData.name.trim() || undefined,
email: formData.email.trim() || undefined,
admin: formData.admin,
subscription: formData.subscription,
packs: formData.packs,
purchases: selectedUser.purchases,
subscriptionFeatures: formData.subscriptionFeatures,
role: formData.role,
}
updateMutation.mutate(userData)
updateMutation.mutate({ userId: selectedUser.id, userData })
}
if (error) {
@ -203,7 +165,7 @@ export default function UsersPage() {
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name or ID..."
placeholder="Search users by name or Telegram ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
@ -228,11 +190,12 @@ export default function UsersPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Admin</TableHead>
<TableHead>Packs</TableHead>
<TableHead>Telegram ID</TableHead>
<TableHead>Role</TableHead>
<TableHead>Stats</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
@ -241,21 +204,27 @@ export default function UsersPage() {
.filter(user =>
search === '' ||
user.name?.toLowerCase().includes(search.toLowerCase()) ||
user.id?.toString().includes(search)
user.telegramId?.includes(search) ||
user.email?.toLowerCase().includes(search.toLowerCase())
)
.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-mono text-sm">{user.id}</TableCell>
<TableCell className="font-medium">{user.name || 'No name'}</TableCell>
<TableCell>{user.email || 'No email'}</TableCell>
<TableCell>{user.email || '-'}</TableCell>
<TableCell className="font-mono text-sm">{user.telegramId || '-'}</TableCell>
<TableCell>
{user.admin ? (
{user.role === 'ADMIN' ? (
<Badge variant="destructive">Admin</Badge>
) : (
<Badge variant="secondary">User</Badge>
)}
</TableCell>
<TableCell>{user.packs.length} packs</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.gamesPlayed} games, {user.gamesWon} wins, {user.totalPoints} pts
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
@ -265,13 +234,6 @@ export default function UsersPage() {
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openPurchasesDialog(user)}
>
<CreditCard className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@ -343,6 +305,16 @@ export default function UsersPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="telegramId">Telegram ID</Label>
<Input
id="telegramId"
value={selectedUser?.telegramId || 'Not linked'}
disabled
className="bg-muted"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
@ -365,93 +337,38 @@ export default function UsersPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="admin"
checked={formData.admin}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
/>
<Label htmlFor="admin">Administrator</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="subscription"
checked={formData.subscription}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, subscription: checked as boolean }))}
/>
<Label htmlFor="subscription">Subscription</Label>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={formData.role}
onValueChange={(value: 'USER' | 'ADMIN') => setFormData(prev => ({ ...prev, role: value }))}
>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USER">User</SelectItem>
<SelectItem value="ADMIN">Administrator</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Packs Owned</Label>
<div className="border rounded-lg max-h-[200px] overflow-y-auto p-2">
{!packsData?.items || packsData.items.length === 0 ? (
<div className="text-sm text-muted-foreground">No packs available</div>
) : (
packsData.items.map((pack) => (
<div key={pack.id} className="flex items-center space-x-2 py-1">
<Checkbox
id={`pack-${pack.id}`}
checked={formData.packs.includes(pack.id)}
onCheckedChange={(checked) => {
if (checked) {
setFormData(prev => ({
...prev,
packs: [...prev.packs, pack.id],
}))
} else {
setFormData(prev => ({
...prev,
packs: prev.packs.filter(id => id !== pack.id),
}))
}
}}
/>
<Label htmlFor={`pack-${pack.id}`} className="text-sm font-normal cursor-pointer">
{pack.title}
</Label>
</div>
))
)}
<Label>Game Statistics</Label>
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="border rounded p-2 text-center">
<div className="text-2xl font-bold">{selectedUser?.gamesPlayed || 0}</div>
<div className="text-muted-foreground">Games</div>
</div>
<div className="border rounded p-2 text-center">
<div className="text-2xl font-bold">{selectedUser?.gamesWon || 0}</div>
<div className="text-muted-foreground">Wins</div>
</div>
<div className="border rounded p-2 text-center">
<div className="text-2xl font-bold">{selectedUser?.totalPoints || 0}</div>
<div className="text-muted-foreground">Points</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
Selected: {formData.packs.length} pack(s)
</p>
</div>
<div className="space-y-2">
<Label>Subscription Features</Label>
<div className="space-y-2">
{['premium', 'unlimited', 'ad_free', 'early_access', 'priority_support'].map((feature) => (
<div key={feature} className="flex items-center space-x-2">
<Checkbox
id={`feature-${feature}`}
checked={formData.subscriptionFeatures.includes(feature)}
onCheckedChange={(checked) => {
if (checked) {
setFormData(prev => ({
...prev,
subscriptionFeatures: [...prev.subscriptionFeatures, feature],
}))
} else {
setFormData(prev => ({
...prev,
subscriptionFeatures: prev.subscriptionFeatures.filter(f => f !== feature),
}))
}
}}
/>
<Label htmlFor={`feature-${feature}`} className="text-sm font-normal cursor-pointer capitalize">
{feature.replace(/_/g, ' ')}
</Label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Selected: {formData.subscriptionFeatures.length} feature(s)
</p>
</div>
</div>
<DialogFooter>
@ -466,62 +383,6 @@ export default function UsersPage() {
</DialogContent>
</Dialog>
{/* User Purchases Dialog */}
<Dialog open={isPurchasesDialogOpen} onOpenChange={setIsPurchasesDialogOpen}>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Purchase History</DialogTitle>
<DialogDescription>
Payment history for user: {selectedUser?.name || selectedUser?.id}
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{userPurchases.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No purchases found for this user
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Currency</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userPurchases.map((payment) => (
<TableRow key={payment.id}>
<TableCell className="font-mono text-sm">{payment.id}</TableCell>
<TableCell>{payment.amount}</TableCell>
<TableCell>{payment.currency}</TableCell>
<TableCell>
<Badge
variant={
payment.status === 'completed' ? 'default' :
payment.status === 'pending' ? 'secondary' : 'destructive'
}
>
{payment.status}
</Badge>
</TableCell>
<TableCell>
{new Date(payment.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
<Button onClick={closePurchasesDialog}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>

View file

@ -1,32 +1,60 @@
// Types based on backend DTOs
export interface EditCardPackDto {
id?: string
title?: string
subtitle?: string
color?: string
cover?: string
size?: number
googlePlayId?: string
rustoreId?: string
appStoreId?: string
price?: string
// Question and Answer structure
export interface Answer {
text: string
points: number
}
export interface Question {
question: string
answers: Answer[]
}
// Question Pack DTOs
export interface CreatePackDto {
name: string
description: string
category: string
isPublic?: boolean
questions: Question[]
}
export interface UpdatePackDto {
name?: string
description?: string
enabled?: boolean
version?: string
order?: number
cardsOrder?: string[]
category?: string
isPublic?: boolean
questions?: Question[]
}
export interface QuestionPackDto {
id: string
name: string
description: string
category: string
isPublic: boolean
questionCount: number
timesUsed: number
rating: number
createdAt: string
updatedAt: string
creator: {
id: string
name?: string
}
}
// For compatibility with existing pack pages
export interface EditCardPackDto extends CreatePackDto {
id?: string
}
export interface CardPackPreviewDto {
id: string
title: string
subtitle?: string
color?: string
cover?: string
cards: number
enabled: boolean
order?: number
}
// User model matching backend Prisma schema
@ -42,44 +70,8 @@ export interface UserDto {
totalPoints: number
}
// Note: PaymentDto removed - no payment system exists in the backend
export interface SubscriptionPlanAdminDto {
id: string
name: string
description?: string
price: number
currency: string
features: string[]
enabled: boolean
order: number
}
export interface PromoCodesCampaignDto {
id: string
title: string
description?: string
codes: string[]
discountPercent: number
validUntil: string
maxUses?: number
usedCount: number
enabled: boolean
}
export interface DiscountCampaignDto {
id: string
title: string
description?: string
discountPercent: number
validFrom: string
validUntil: string
enabled: boolean
packIds?: string[]
userIds?: string[]
maxUses?: number
usedCount: number
}
// Note: Payment, Subscription, PromoCodes, and Discount DTOs removed
// These features are not implemented in the backend
// API Response types
export interface ApiResponse<T> {