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,
|
page: params?.page || 1,
|
||||||
limit: params?.limit || 20,
|
limit: params?.limit || 20,
|
||||||
search: params?.search,
|
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) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||||
|
|
||||||
|
|
@ -83,7 +96,16 @@ export const packsApi = {
|
||||||
getPack: async (packId: string): Promise<EditCardPackDto> => {
|
getPack: async (packId: string): Promise<EditCardPackDto> => {
|
||||||
try {
|
try {
|
||||||
const response = await adminApiClient.get(`/api/admin/packs/${packId}`)
|
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) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
|
||||||
|
|
@ -110,8 +132,22 @@ export const packsApi = {
|
||||||
// Create or update pack
|
// Create or update pack
|
||||||
upsertPack: async (pack: EditCardPackDto): Promise<{ success: boolean; pack: EditCardPackDto }> => {
|
upsertPack: async (pack: EditCardPackDto): Promise<{ success: boolean; pack: EditCardPackDto }> => {
|
||||||
try {
|
try {
|
||||||
const response = await adminApiClient.post('/api/admin/packs', pack)
|
const isUpdate = pack.id && pack.id.length > 0
|
||||||
return response.data
|
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) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
||||||
const isUpdate = pack.id && pack.id.length > 0
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { usersApi } from '@/api/users'
|
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 type { AxiosError } from 'axios'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -34,10 +34,9 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { packsApi } from '@/api/packs'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
|
import { Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -46,18 +45,13 @@ export default function UsersPage() {
|
||||||
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null)
|
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null)
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
const [isPurchasesDialogOpen, setIsPurchasesDialogOpen] = useState(false)
|
|
||||||
const [userToDelete, setUserToDelete] = useState<UserDto | null>(null)
|
const [userToDelete, setUserToDelete] = useState<UserDto | null>(null)
|
||||||
const [userPurchases, setUserPurchases] = useState<PaymentDto[]>([])
|
|
||||||
|
|
||||||
// Form state
|
// Form state matching new UserDto
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
admin: false,
|
role: 'USER' as 'USER' | 'ADMIN',
|
||||||
subscription: false,
|
|
||||||
packs: [] as string[],
|
|
||||||
subscriptionFeatures: [] as string[],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
@ -68,15 +62,10 @@ export default function UsersPage() {
|
||||||
queryFn: () => usersApi.getUsers({ page, limit }),
|
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
|
// Mutations
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (user: UserDto) => usersApi.upsertUser(user),
|
mutationFn: ({ userId, userData }: { userId: string; userData: Partial<UserDto> }) =>
|
||||||
|
usersApi.updateUser(userId, userData),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||||
toast.success('User updated successfully')
|
toast.success('User updated successfully')
|
||||||
|
|
@ -107,10 +96,7 @@ export default function UsersPage() {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: user.name || '',
|
name: user.name || '',
|
||||||
email: user.email || '',
|
email: user.email || '',
|
||||||
admin: user.admin,
|
role: user.role,
|
||||||
subscription: user.subscription || false,
|
|
||||||
packs: [...user.packs],
|
|
||||||
subscriptionFeatures: [...user.subscriptionFeatures],
|
|
||||||
})
|
})
|
||||||
setIsEditDialogOpen(true)
|
setIsEditDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
@ -120,25 +106,6 @@ export default function UsersPage() {
|
||||||
setSelectedUser(null)
|
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) => {
|
const handleDelete = (user: UserDto) => {
|
||||||
setUserToDelete(user)
|
setUserToDelete(user)
|
||||||
setIsDeleteDialogOpen(true)
|
setIsDeleteDialogOpen(true)
|
||||||
|
|
@ -146,7 +113,7 @@ export default function UsersPage() {
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (userToDelete?.id) {
|
if (userToDelete?.id) {
|
||||||
deleteMutation.mutate(userToDelete.id.toString())
|
deleteMutation.mutate(userToDelete.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,18 +125,13 @@ export default function UsersPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const userData: UserDto = {
|
const userData: Partial<UserDto> = {
|
||||||
id: selectedUser.id,
|
|
||||||
name: formData.name.trim() || undefined,
|
name: formData.name.trim() || undefined,
|
||||||
email: formData.email.trim() || undefined,
|
email: formData.email.trim() || undefined,
|
||||||
admin: formData.admin,
|
role: formData.role,
|
||||||
subscription: formData.subscription,
|
|
||||||
packs: formData.packs,
|
|
||||||
purchases: selectedUser.purchases,
|
|
||||||
subscriptionFeatures: formData.subscriptionFeatures,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMutation.mutate(userData)
|
updateMutation.mutate({ userId: selectedUser.id, userData })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -203,7 +165,7 @@ export default function UsersPage() {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search users by name or ID..."
|
placeholder="Search users by name or Telegram ID..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="max-w-sm"
|
className="max-w-sm"
|
||||||
|
|
@ -228,11 +190,12 @@ export default function UsersPage() {
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Admin</TableHead>
|
<TableHead>Telegram ID</TableHead>
|
||||||
<TableHead>Packs</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Stats</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -241,21 +204,27 @@ export default function UsersPage() {
|
||||||
.filter(user =>
|
.filter(user =>
|
||||||
search === '' ||
|
search === '' ||
|
||||||
user.name?.toLowerCase().includes(search.toLowerCase()) ||
|
user.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
user.id?.toString().includes(search)
|
user.telegramId?.includes(search) ||
|
||||||
|
user.email?.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
.map((user) => (
|
.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell className="font-mono text-sm">{user.id}</TableCell>
|
|
||||||
<TableCell className="font-medium">{user.name || 'No name'}</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>
|
<TableCell>
|
||||||
{user.admin ? (
|
{user.role === 'ADMIN' ? (
|
||||||
<Badge variant="destructive">Admin</Badge>
|
<Badge variant="destructive">Admin</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary">User</Badge>
|
<Badge variant="secondary">User</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -265,13 +234,6 @@ export default function UsersPage() {
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openPurchasesDialog(user)}
|
|
||||||
>
|
|
||||||
<CreditCard className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -343,6 +305,16 @@ export default function UsersPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">Name</Label>
|
||||||
|
|
@ -365,93 +337,38 @@ export default function UsersPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<Label htmlFor="role">Role</Label>
|
||||||
<Checkbox
|
<Select
|
||||||
id="admin"
|
value={formData.role}
|
||||||
checked={formData.admin}
|
onValueChange={(value: 'USER' | 'ADMIN') => setFormData(prev => ({ ...prev, role: value }))}
|
||||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
|
>
|
||||||
/>
|
<SelectTrigger id="role">
|
||||||
<Label htmlFor="admin">Administrator</Label>
|
<SelectValue />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
<div className="flex items-center space-x-2">
|
<SelectContent>
|
||||||
<Checkbox
|
<SelectItem value="USER">User</SelectItem>
|
||||||
id="subscription"
|
<SelectItem value="ADMIN">Administrator</SelectItem>
|
||||||
checked={formData.subscription}
|
</SelectContent>
|
||||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, subscription: checked as boolean }))}
|
</Select>
|
||||||
/>
|
|
||||||
<Label htmlFor="subscription">Subscription</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Packs Owned</Label>
|
<Label>Game Statistics</Label>
|
||||||
<div className="border rounded-lg max-h-[200px] overflow-y-auto p-2">
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
{!packsData?.items || packsData.items.length === 0 ? (
|
<div className="border rounded p-2 text-center">
|
||||||
<div className="text-sm text-muted-foreground">No packs available</div>
|
<div className="text-2xl font-bold">{selectedUser?.gamesPlayed || 0}</div>
|
||||||
) : (
|
<div className="text-muted-foreground">Games</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>
|
</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>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="border rounded p-2 text-center">
|
||||||
Selected: {formData.packs.length} pack(s)
|
<div className="text-2xl font-bold">{selectedUser?.totalPoints || 0}</div>
|
||||||
</p>
|
<div className="text-muted-foreground">Points</div>
|
||||||
</div>
|
</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>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Selected: {formData.subscriptionFeatures.length} feature(s)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
@ -466,62 +383,6 @@ export default function UsersPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,60 @@
|
||||||
// Types based on backend DTOs
|
// Types based on backend DTOs
|
||||||
|
|
||||||
export interface EditCardPackDto {
|
// Question and Answer structure
|
||||||
id?: string
|
export interface Answer {
|
||||||
title?: string
|
text: string
|
||||||
subtitle?: string
|
points: number
|
||||||
color?: string
|
}
|
||||||
cover?: string
|
|
||||||
size?: number
|
export interface Question {
|
||||||
googlePlayId?: string
|
question: string
|
||||||
rustoreId?: string
|
answers: Answer[]
|
||||||
appStoreId?: string
|
}
|
||||||
price?: string
|
|
||||||
|
// Question Pack DTOs
|
||||||
|
export interface CreatePackDto {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
isPublic?: boolean
|
||||||
|
questions: Question[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePackDto {
|
||||||
|
name?: string
|
||||||
description?: string
|
description?: string
|
||||||
enabled?: boolean
|
category?: string
|
||||||
version?: string
|
isPublic?: boolean
|
||||||
order?: number
|
questions?: Question[]
|
||||||
cardsOrder?: string[]
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface CardPackPreviewDto {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
|
||||||
color?: string
|
|
||||||
cover?: string
|
|
||||||
cards: number
|
cards: number
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
order?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// User model matching backend Prisma schema
|
// User model matching backend Prisma schema
|
||||||
|
|
@ -42,44 +70,8 @@ export interface UserDto {
|
||||||
totalPoints: number
|
totalPoints: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: PaymentDto removed - no payment system exists in the backend
|
// Note: Payment, Subscription, PromoCodes, and Discount DTOs removed
|
||||||
|
// These features are not implemented 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue