admoin
This commit is contained in:
parent
841a6bdc74
commit
cb29331d58
7 changed files with 149 additions and 187 deletions
|
|
@ -39,14 +39,12 @@ describe('TestPacksManager', () => {
|
|||
title: 'Pack One',
|
||||
cards: 10,
|
||||
enabled: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
title: 'Pack Two',
|
||||
cards: 5,
|
||||
enabled: true,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ function TestPacksManagerInner({
|
|||
const fallbackId = Array.from(nextSelected)[0]
|
||||
const fallback = fallbackId ? packsById.get(fallbackId) : undefined
|
||||
if (fallback?.color) onSelectedPackColorChange(fallback.color)
|
||||
else onSelectedPackColorChange('')
|
||||
}
|
||||
|
||||
return
|
||||
|
|
@ -140,8 +141,8 @@ function TestPacksManagerInner({
|
|||
})
|
||||
}
|
||||
|
||||
if (onSelectedPackColorChange) {
|
||||
onSelectedPackColorChange(pack.color ?? '')
|
||||
if (onSelectedPackColorChange && pack.color) {
|
||||
onSelectedPackColorChange(pack.color)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export function InputButtonsQuestionForm({
|
|||
<ImageUpload
|
||||
label="Question Image"
|
||||
value={image}
|
||||
onChange={(value) => setImage(value || '')}
|
||||
onChange={(value: string | undefined) => setImage(value || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ export function InputButtonsQuestionForm({
|
|||
<ImageUpload
|
||||
label="Image"
|
||||
value={button.image || ''}
|
||||
onChange={(value) =>
|
||||
onChange={(value: string | undefined) =>
|
||||
updateButton(index, { image: value || undefined })
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export function SimpleQuestionForm({
|
|||
<ImageUpload
|
||||
label="Question Image"
|
||||
value={image}
|
||||
onChange={(value) => setImage(value || '')}
|
||||
onChange={(value: string | undefined) => setImage(value || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ export function SimpleQuestionForm({
|
|||
<ImageUpload
|
||||
label="Image"
|
||||
value={button.image || ''}
|
||||
onChange={(value) =>
|
||||
onChange={(value: string | undefined) =>
|
||||
updateButton(index, { image: value || undefined })
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
77
admin/src/components/ui/image-upload.tsx
Normal file
77
admin/src/components/ui/image-upload.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ImageIcon, X } from 'lucide-react'
|
||||
|
||||
interface ImageUploadProps {
|
||||
label?: string
|
||||
value?: string
|
||||
onChange: (value: string | undefined) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ImageUpload({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder = 'Enter image URL',
|
||||
}: ImageUploadProps) {
|
||||
const [url, setUrl] = useState(value || '')
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setUrl(newValue)
|
||||
onChange(newValue.trim() || undefined)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setUrl('')
|
||||
onChange(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <Label>{label}</Label>}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<ImageIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{url && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={url}
|
||||
alt="Preview"
|
||||
className="max-h-32 w-auto rounded border"
|
||||
onError={(e) => {
|
||||
// Hide broken images
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||
import { toast } from 'sonner'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse, Question } from '@/types/models'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -36,8 +36,6 @@ import {
|
|||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
export default function PacksPage() {
|
||||
|
|
@ -52,19 +50,11 @@ export default function PacksPage() {
|
|||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '',
|
||||
enabled: true,
|
||||
googlePlayId: '',
|
||||
rustoreId: '',
|
||||
appStoreId: '',
|
||||
price: '',
|
||||
order: 0,
|
||||
version: '',
|
||||
size: 0,
|
||||
cover: undefined as string | undefined,
|
||||
category: '',
|
||||
isPublic: true,
|
||||
questions: [] as Question[],
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -136,19 +126,11 @@ export default function PacksPage() {
|
|||
const openCreateDialog = () => {
|
||||
setSelectedPack(null)
|
||||
setFormData({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '',
|
||||
enabled: true,
|
||||
googlePlayId: '',
|
||||
rustoreId: '',
|
||||
appStoreId: '',
|
||||
price: '',
|
||||
order: 0,
|
||||
version: '',
|
||||
size: 0,
|
||||
cover: undefined,
|
||||
category: '',
|
||||
isPublic: true,
|
||||
questions: [],
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
|
@ -158,19 +140,11 @@ export default function PacksPage() {
|
|||
const fullPack = await packsApi.getPack(pack.id)
|
||||
setSelectedPack(fullPack)
|
||||
setFormData({
|
||||
title: fullPack.title || '',
|
||||
subtitle: fullPack.subtitle || '',
|
||||
name: fullPack.name || '',
|
||||
description: fullPack.description || '',
|
||||
color: fullPack.color || '',
|
||||
enabled: fullPack.enabled ?? true,
|
||||
googlePlayId: fullPack.googlePlayId || '',
|
||||
rustoreId: fullPack.rustoreId || '',
|
||||
appStoreId: fullPack.appStoreId || '',
|
||||
price: fullPack.price || '',
|
||||
order: fullPack.order || 0,
|
||||
version: fullPack.version || '',
|
||||
size: fullPack.size || 0,
|
||||
cover: fullPack.cover,
|
||||
category: fullPack.category || '',
|
||||
isPublic: fullPack.isPublic ?? true,
|
||||
questions: fullPack.questions || [],
|
||||
})
|
||||
|
||||
setIsDialogOpen(true)
|
||||
|
|
@ -191,27 +165,29 @@ export default function PacksPage() {
|
|||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
toast.error('Title is required')
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.description.trim()) {
|
||||
toast.error('Description is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.category.trim()) {
|
||||
toast.error('Category is required')
|
||||
return
|
||||
}
|
||||
|
||||
const packData: EditCardPackDto = {
|
||||
// Only include id for updates, not for new packs
|
||||
...(selectedPack && { id: selectedPack.id }),
|
||||
title: formData.title.trim(),
|
||||
subtitle: formData.subtitle.trim() || undefined,
|
||||
description: formData.description.trim() || undefined,
|
||||
color: formData.color.trim() || undefined,
|
||||
enabled: formData.enabled,
|
||||
googlePlayId: formData.googlePlayId.trim() || undefined,
|
||||
rustoreId: formData.rustoreId.trim() || undefined,
|
||||
appStoreId: formData.appStoreId.trim() || undefined,
|
||||
price: formData.price.trim() || undefined,
|
||||
order: formData.order,
|
||||
version: formData.version.trim() || undefined,
|
||||
size: formData.size > 0 ? formData.size : undefined,
|
||||
cover: formData.cover || undefined,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
category: formData.category.trim(),
|
||||
isPublic: formData.isPublic,
|
||||
questions: formData.questions,
|
||||
}
|
||||
|
||||
if (selectedPack) {
|
||||
|
|
@ -336,12 +312,7 @@ export default function PacksPage() {
|
|||
<TableRow key={pack.id}>
|
||||
<TableCell className="font-mono text-sm">{pack.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{pack.title}</div>
|
||||
{pack.subtitle && (
|
||||
<div className="text-sm text-muted-foreground">{pack.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium">{pack.title}</div>
|
||||
</TableCell>
|
||||
<TableCell>{pack.cards}</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -426,146 +397,57 @@ export default function PacksPage() {
|
|||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Pack title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtitle">Subtitle</Label>
|
||||
<Input
|
||||
id="subtitle"
|
||||
value={formData.subtitle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, subtitle: e.target.value }))}
|
||||
placeholder="Pack subtitle"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Pack name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Pack description"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<ColorPaletteInput
|
||||
id="color"
|
||||
value={formData.color}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, color: value }))}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
placeholder="#FF0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input
|
||||
id="order"
|
||||
type="number"
|
||||
value={formData.order}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, order: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Input
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
|
||||
placeholder="Pack category"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enabled"
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, enabled: checked as boolean }))}
|
||||
id="isPublic"
|
||||
checked={formData.isPublic}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isPublic: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
<Label htmlFor="isPublic">Public</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Store IDs</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="googlePlayId" className="text-xs">Google Play</Label>
|
||||
<Input
|
||||
id="googlePlayId"
|
||||
value={formData.googlePlayId}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, googlePlayId: e.target.value }))}
|
||||
placeholder="com.example.app"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rustoreId" className="text-xs">RuStore</Label>
|
||||
<Input
|
||||
id="rustoreId"
|
||||
value={formData.rustoreId}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, rustoreId: e.target.value }))}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="appStoreId" className="text-xs">App Store</Label>
|
||||
<Input
|
||||
id="appStoreId"
|
||||
value={formData.appStoreId}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, appStoreId: e.target.value }))}
|
||||
placeholder="1234567890"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Price</Label>
|
||||
<Input
|
||||
id="price"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||||
placeholder="Free, $1.99, etc."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="size">Size</Label>
|
||||
<Input
|
||||
id="size"
|
||||
type="number"
|
||||
value={formData.size}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, size: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Number of cards in the pack (auto-calculated if not set)
|
||||
<Label>Questions</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formData.questions.length} question(s) in this pack
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Questions can be managed through the question editor
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Cover Image"
|
||||
value={formData.cover}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export interface CardPackPreviewDto {
|
|||
title: string
|
||||
cards: number
|
||||
enabled: boolean
|
||||
// Optional properties that may be used in UI but not in backend
|
||||
subtitle?: string
|
||||
color?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
// User model matching backend Prisma schema
|
||||
|
|
|
|||
Loading…
Reference in a new issue