admin
This commit is contained in:
parent
a61008f71a
commit
841a6bdc74
7 changed files with 156 additions and 1325 deletions
|
|
@ -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}`)
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue